improvement/v1-demo-improvements. #62
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "LabCaseActivityType" ADD VALUE 'TASK_ASSIGNED';
|
||||
@@ -429,6 +429,7 @@ enum LabCaseActivityType {
|
||||
CASE_IMPORTANT
|
||||
CASE_AMENDED
|
||||
TASK_COMPLETED
|
||||
TASK_ASSIGNED
|
||||
}
|
||||
|
||||
enum LabCaseTabReadTarget {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,19 +390,45 @@ export class LabCaseActivityService {
|
||||
};
|
||||
|
||||
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({
|
||||
where: {
|
||||
type: { in: types },
|
||||
createdAt: { gt: since },
|
||||
labCase: labCaseScope,
|
||||
// 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 } }],
|
||||
},
|
||||
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> {
|
||||
// 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;
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,7 +4,8 @@ export type LabCaseActivityType =
|
||||
| 'LAB_COMMENT'
|
||||
| 'CASE_IMPORTANT'
|
||||
| 'CASE_AMENDED'
|
||||
| 'TASK_COMPLETED';
|
||||
| 'TASK_COMPLETED'
|
||||
| 'TASK_ASSIGNED';
|
||||
|
||||
export interface LabCaseActivityItem {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user