Add real-time updates for meeting alerts across user devices

Introduce real-time event listeners and emitters to synchronize meeting alert status changes, such as "Done" or "Dismissed", across a user's multiple open tabs and devices. This update refactors the `executive-meetings.ts` route to trigger a per-user socket event upon state transitions, which is then handled by `use-notifications-socket.ts` to invalidate the alert state query, ensuring near-instantaneous UI updates. New tests in `executive-meetings-upcoming-alert.spec.mjs` verify the cross-tab synchronization for both "Done" and "Dismiss" actions, confirming that only the acting user's alerts are affected.
This commit is contained in:
Riyadh
2026-05-01 14:23:01 +00:00
parent 5c952d6716
commit 289435cfca
4 changed files with 265 additions and 0 deletions
+29
View File
@@ -91,6 +91,35 @@ export async function emitExecutiveMeetingsDaysChanged(
}
}
/**
* #277: Notify a single user that their per-user alert state for a meeting
* has changed (Done / Dismiss). The alert state is private to each user —
* Ahmed clicking "Done" must NOT clear Sara's alert — so this event is
* emitted ONLY to the acting user's `user:${userId}` room. All of that
* user's open tabs / devices then invalidate their alert-state query and
* the alert disappears within ~1s instead of waiting for the 30s poll.
*
* Meeting mutations (postpone / reschedule / cancel) are deliberately NOT
* routed through this event — those modify the meeting itself and continue
* to broadcast globally via `executive_meetings_changed`.
*/
export async function emitExecutiveMeetingAlertStateChanged(
userId: number,
payload: {
meetingId: number;
dismissed: boolean;
acknowledged: boolean;
},
): Promise<void> {
if (!Number.isInteger(userId) || userId <= 0) return;
if (!Number.isInteger(payload.meetingId) || payload.meetingId <= 0) return;
const { io } = await import("../index.js");
io.to(`user:${userId}`).emit(
"executive_meeting_alert_state_changed",
payload,
);
}
/**
* #216: Resolve every user that currently holds `permissionId` (via any role
* they hold directly through `user_roles` or transitively through
@@ -40,6 +40,7 @@ import {
import {
emitExecutiveMeetingsDayChanged,
emitExecutiveMeetingsDaysChanged,
emitExecutiveMeetingAlertStateChanged,
} from "../lib/realtime";
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
import { ObjectStorageService } from "../lib/objectStorage";
@@ -865,6 +866,10 @@ router.post(
return;
}
const userId = req.session.userId;
// #277: Track whether this request actually transitioned the per-user
// row so we only emit a realtime event on real state changes — keeps
// the cross-tab signal noise-free when two tabs POST the same action.
let stateChanged = false;
await db.transaction(async (tx) => {
// Race-safe upsert: when two tabs surface the same alert at once they
// both POST {action:"shown"}; using ON CONFLICT DO NOTHING means only
@@ -901,6 +906,7 @@ router.post(
newValue: { userId },
performedBy: userId,
});
stateChanged = true;
} else if (body.action === "dismissed") {
await logAudit(tx, {
action: "meeting_alert_dismissed",
@@ -909,6 +915,7 @@ router.post(
newValue: { userId },
performedBy: userId,
});
stateChanged = true;
}
return;
}
@@ -934,6 +941,7 @@ router.post(
newValue: { userId },
performedBy: userId,
});
stateChanged = true;
}
} else if (body.action === "dismissed") {
const upd = await tx
@@ -955,9 +963,20 @@ router.post(
newValue: { userId },
performedBy: userId,
});
stateChanged = true;
}
}
});
// #277: Push the new alert state to *only* the acting user's sockets
// so all of their open tabs / devices clear the alert within ~1s
// instead of waiting on the 30s poll. Other users are unaffected.
if (stateChanged) {
void emitExecutiveMeetingAlertStateChanged(userId, {
meetingId: id,
dismissed: body.action === "dismissed",
acknowledged: body.action === "acknowledged",
});
}
res.json({ ok: true });
},
);