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:
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user