improvement: task assignment notif added to notifications feature.
This commit is contained in:
@@ -6,7 +6,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
# Tab badges (Cases / Tasks / Treatment)
|
# 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.
|
- **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`.
|
- **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).
|
- **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).
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Full tab-badge map: `.cursor/skills/tab-badges/SKILL.md`.
|
|||||||
|
|
||||||
## Emit sites (parallel to LabCaseActivity)
|
## 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
|
|||||||
|
|
||||||
## Models
|
## 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.
|
- **`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
|
- **`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 |
|
| Org | Tab | Activity types |
|
||||||
|-----|-----|----------------|
|
|-----|-----|----------------|
|
||||||
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
|
| 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** |
|
| 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`.
|
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` |
|
| Comment | `lab-case-comments.service` → `CLINIC_COMMENT` / `LAB_COMMENT` |
|
||||||
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
|
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
|
||||||
| Task completed | `tasks.service` `updateStatus` → `TASK_COMPLETED` |
|
| 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).
|
After mutations, frontend calls `notifyTabBadgesChanged()` (window event).
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ After mutations, frontend calls `notifyTabBadgesChanged()` (window event).
|
|||||||
- Tasks → silent task list
|
- Tasks → silent task list
|
||||||
- Treatment (`TreatmentWorkspace`) → silent patient lab cases + unread rail
|
- Treatment (`TreatmentWorkspace`) → silent patient lab cases + unread rail
|
||||||
- Orgs → silent list on `pending-connections-changed` only
|
- 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)
|
## Out of scope (later steps)
|
||||||
|
|
||||||
- Push / email
|
- Push / email
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "LabCaseActivityType" ADD VALUE 'TASK_ASSIGNED';
|
||||||
@@ -429,6 +429,7 @@ enum LabCaseActivityType {
|
|||||||
CASE_IMPORTANT
|
CASE_IMPORTANT
|
||||||
CASE_AMENDED
|
CASE_AMENDED
|
||||||
TASK_COMPLETED
|
TASK_COMPLETED
|
||||||
|
TASK_ASSIGNED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LabCaseTabReadTarget {
|
enum LabCaseTabReadTarget {
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
|||||||
LabCaseActivityType.CASE_IMPORTANT,
|
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[] = [
|
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
||||||
LabCaseActivityType.TASK_COMPLETED,
|
LabCaseActivityType.TASK_COMPLETED,
|
||||||
LabCaseActivityType.LAB_COMMENT,
|
LabCaseActivityType.LAB_COMMENT,
|
||||||
|
LabCaseActivityType.TASK_ASSIGNED,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Clinic Treatment tab — visible lab comments and task progress. */
|
/** Clinic Treatment tab — visible lab comments and task progress. */
|
||||||
|
|||||||
@@ -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({
|
void this.userNotifications.notify({
|
||||||
organizationId: labOrganizationId,
|
organizationId: labOrganizationId,
|
||||||
type: UserNotificationType.TASK_ASSIGNED,
|
type: UserNotificationType.TASK_ASSIGNED,
|
||||||
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`,
|
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
payload: { labCaseId, taskId },
|
payload: { labCaseId, taskId, assigneeUserId },
|
||||||
recipientUserIds: [assigneeUserId],
|
recipientUserIds: [assigneeUserId],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,7 +318,10 @@ export class LabCaseActivityService {
|
|||||||
const commentId = payload?.commentId;
|
const commentId = payload?.commentId;
|
||||||
if (typeof commentId === 'string') commentIds.push(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;
|
const taskId = payload?.taskId;
|
||||||
if (typeof taskId === 'string') taskIds.push(taskId);
|
if (typeof taskId === 'string') taskIds.push(taskId);
|
||||||
}
|
}
|
||||||
@@ -387,19 +390,45 @@ export class LabCaseActivityService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
|
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
|
||||||
|
const includesTaskAssigned = types.includes(LabCaseActivityType.TASK_ASSIGNED);
|
||||||
|
const otherTypes = types.filter((type) => type !== LabCaseActivityType.TASK_ASSIGNED);
|
||||||
|
|
||||||
return this.prisma.labCaseActivity.count({
|
// TASK_ASSIGNED: count for the assignee only (including self-assign).
|
||||||
where: {
|
// Other task-tab types: org-wide, excluding events the current user authored.
|
||||||
type: { in: types },
|
const typeFilter: Prisma.LabCaseActivityWhereInput = includesTaskAssigned
|
||||||
createdAt: { gt: since },
|
? {
|
||||||
labCase: labCaseScope,
|
OR: [
|
||||||
|
...(otherTypes.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
AND: [
|
AND: [
|
||||||
|
{ type: { in: otherTypes } },
|
||||||
{
|
{
|
||||||
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
|
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
|
||||||
},
|
},
|
||||||
clinicLabCommentFilter,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
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: {
|
||||||
|
createdAt: { gt: since },
|
||||||
|
labCase: labCaseScope,
|
||||||
|
AND: [clinicLabCommentFilter, typeFilter],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,8 +182,9 @@ export class UserNotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async notify(input: FanoutInput): Promise<void> {
|
async notify(input: FanoutInput): Promise<void> {
|
||||||
|
// Explicit recipients keep the actor (e.g. self-assign). Fan-out still skips the actor.
|
||||||
const recipientIds = input.recipientUserIds?.length
|
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);
|
: await this.resolveRecipients(input);
|
||||||
|
|
||||||
if (recipientIds.length === 0) return;
|
if (recipientIds.length === 0) return;
|
||||||
|
|||||||
@@ -761,6 +761,7 @@
|
|||||||
"activityClinicComment": "{actor}: “{preview}” · {date}",
|
"activityClinicComment": "{actor}: “{preview}” · {date}",
|
||||||
"activityLabComment": "{actor}: “{preview}” · {date}",
|
"activityLabComment": "{actor}: “{preview}” · {date}",
|
||||||
"activityTaskCompleted": "{step} completed by {actor} · {date}",
|
"activityTaskCompleted": "{step} completed by {actor} · {date}",
|
||||||
|
"activityTaskAssigned": "{step} assigned by {actor} · {date}",
|
||||||
"activityCaseImportant": "Marked important by {actor} · {date}",
|
"activityCaseImportant": "Marked important by {actor} · {date}",
|
||||||
"activityCaseAmended": "Case updated by {actor} · {date}",
|
"activityCaseAmended": "Case updated by {actor} · {date}",
|
||||||
"activityGeneric": "Update · {date}",
|
"activityGeneric": "Update · {date}",
|
||||||
|
|||||||
@@ -762,6 +762,7 @@
|
|||||||
"activityClinicComment": "{actor}: «{preview}» · {date}",
|
"activityClinicComment": "{actor}: «{preview}» · {date}",
|
||||||
"activityLabComment": "{actor}: «{preview}» · {date}",
|
"activityLabComment": "{actor}: «{preview}» · {date}",
|
||||||
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
|
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
|
||||||
|
"activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}",
|
||||||
"activityCaseImportant": "مهم علامتگذاری شد توسط {actor} · {date}",
|
"activityCaseImportant": "مهم علامتگذاری شد توسط {actor} · {date}",
|
||||||
"activityCaseAmended": "پرونده بهروزرسانی شد توسط {actor} · {date}",
|
"activityCaseAmended": "پرونده بهروزرسانی شد توسط {actor} · {date}",
|
||||||
"activityGeneric": "بهروزرسانی · {date}",
|
"activityGeneric": "بهروزرسانی · {date}",
|
||||||
|
|||||||
@@ -761,6 +761,7 @@
|
|||||||
"activityClinicComment": "{actor}: “{preview}” · {date}",
|
"activityClinicComment": "{actor}: “{preview}” · {date}",
|
||||||
"activityLabComment": "{actor}: “{preview}” · {date}",
|
"activityLabComment": "{actor}: “{preview}” · {date}",
|
||||||
"activityTaskCompleted": "{step} voltooid door {actor} · {date}",
|
"activityTaskCompleted": "{step} voltooid door {actor} · {date}",
|
||||||
|
"activityTaskAssigned": "{step} toegewezen door {actor} · {date}",
|
||||||
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
|
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
|
||||||
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
|
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
|
||||||
"activityGeneric": "Update · {date}",
|
"activityGeneric": "Update · {date}",
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ export function CasesPage() {
|
|||||||
try {
|
try {
|
||||||
const response = await casesApi.assignTask(selectedCaseId, taskId, assigneeUserId);
|
const response = await casesApi.assignTask(selectedCaseId, taskId, assigneeUserId);
|
||||||
setSelectedCase(response.data);
|
setSelectedCase(response.data);
|
||||||
|
notifyTabBadgesChanged();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(getUserFacingError(error, tErrors, t('errorAssignTask')));
|
toast.showError(getUserFacingError(error, tErrors, t('errorAssignTask')));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export function formatLabCaseActivityLine(
|
|||||||
actor,
|
actor,
|
||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
|
case 'TASK_ASSIGNED':
|
||||||
|
return t('activityTaskAssigned', {
|
||||||
|
step: activity.stepLabel ?? t('activityUnknownStep'),
|
||||||
|
actor,
|
||||||
|
date,
|
||||||
|
});
|
||||||
case 'CASE_IMPORTANT':
|
case 'CASE_IMPORTANT':
|
||||||
return t('activityCaseImportant', { actor, date });
|
return t('activityCaseImportant', { actor, date });
|
||||||
case 'CASE_AMENDED':
|
case 'CASE_AMENDED':
|
||||||
|
|||||||
@@ -78,11 +78,16 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
|
|||||||
const socket = io(`${apiOrigin()}/realtime`, {
|
const socket = io(`${apiOrigin()}/realtime`, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
|
// Avoid hammering the gateway with an expired cookie; we reconnect after refresh.
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
});
|
});
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
socket.on('connect', () => setConnected(true));
|
socket.on('connect', () => setConnected(true));
|
||||||
socket.on('disconnect', () => setConnected(false));
|
socket.on('disconnect', () => setConnected(false));
|
||||||
|
socket.on('connect_error', () => setConnected(false));
|
||||||
socket.on('notification.created', (payload: { notification?: UserNotificationItem }) => {
|
socket.on('notification.created', (payload: { notification?: UserNotificationItem }) => {
|
||||||
if (payload?.notification) {
|
if (payload?.notification) {
|
||||||
setLastNotification(payload.notification);
|
setLastNotification(payload.notification);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ export type LabCaseActivityType =
|
|||||||
| 'LAB_COMMENT'
|
| 'LAB_COMMENT'
|
||||||
| 'CASE_IMPORTANT'
|
| 'CASE_IMPORTANT'
|
||||||
| 'CASE_AMENDED'
|
| 'CASE_AMENDED'
|
||||||
| 'TASK_COMPLETED';
|
| 'TASK_COMPLETED'
|
||||||
|
| 'TASK_ASSIGNED';
|
||||||
|
|
||||||
export interface LabCaseActivityItem {
|
export interface LabCaseActivityItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user