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 });
},
);
@@ -92,6 +92,21 @@ export function useNotificationsSocket() {
});
});
// #277: Per-user push for the 5-min upcoming-meeting alert. The server
// only emits this to `user:${userId}`, so receiving it means *this*
// user (in this or another of their tabs / devices) acknowledged or
// dismissed an alert. Invalidate the alert-state query so the open
// alert card hides within ~1s instead of waiting on the 30s poll.
// Other users do not receive this event, so their alert stays visible.
socket.on(
"executive_meeting_alert_state_changed",
() => {
queryClient.invalidateQueries({
queryKey: ["/api/executive-meetings/alert-state"],
});
},
);
socket.on(
"role_permissions_changed",
(payload?: { roleId?: number }) => {
@@ -617,3 +617,205 @@ test("Upcoming-meeting alert: Arabic locale renders the RTL alert with Arabic ti
// Mark used so the afterAll cleanup picks it up via createdMeetingIds.
expect(m.id).toBeGreaterThan(0);
});
// #277: Realtime push tests.
//
// The 5-min upcoming-meeting alert state is per-user. The server emits
// `executive_meeting_alert_state_changed` ONLY to the acting user's
// `user:${userId}` socket room so:
// (a) all of *that* user's open tabs / devices clear within ~1s, and
// (b) other users' alerts are unaffected.
// These two browser-level tests verify both invariants without waiting
// on the 30s polling fallback.
test("Upcoming-meeting alert: realtime — same user, second tab clears within ~3s of Done in the first tab", async ({
browser,
}) => {
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} rt-cross-tab AR`,
titleEn: `${TEST_TAG} rt-cross-tab EN`,
deltaMins: 3,
});
// One browser context (= one logged-in user) with two tabs.
const ctx = await browser.newContext();
try {
const tabA = await ctx.newPage();
const tabB = await ctx.newPage();
await setLang(tabA, "en");
await setLang(tabB, "en");
// Login once on tabA — the session cookie is shared across the
// context so tabB inherits the same user without a second login.
await loginViaUi(tabA, "admin", "admin123");
await Promise.all([tabA.goto("/"), tabB.goto("/")]);
const alertA = tabA.getByTestId("upcoming-meeting-alert");
const alertB = tabB.getByTestId("upcoming-meeting-alert");
await expect(alertA).toBeVisible({ timeout: 15_000 });
await expect(alertB).toBeVisible({ timeout: 15_000 });
// Acknowledge in tabA. The server emits the per-user event to
// user:${admin.id}, which both tabs receive — but tabB has *not*
// polled yet (poll is 30s) so any sub-30s hide on tabB proves the
// socket invalidation worked.
await tabA.getByTestId("alert-done").click();
await expect(alertA).toBeHidden({ timeout: 5_000 });
await expect(alertB).toBeHidden({ timeout: 5_000 });
} finally {
await ctx.close();
}
expect(m.id).toBeGreaterThan(0);
});
test("Upcoming-meeting alert: realtime — same user, Dismiss in tab A clears tab B within ~5s", async ({
browser,
}) => {
// Mirrors the Done cross-tab test for the Dismiss action so both
// per-user state transitions (acknowledged + dismissed) are proven
// to push to the user's other tabs without waiting for the 30s poll.
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} rt-dismiss AR`,
titleEn: `${TEST_TAG} rt-dismiss EN`,
deltaMins: 3,
});
const ctx = await browser.newContext();
try {
const tabA = await ctx.newPage();
const tabB = await ctx.newPage();
await setLang(tabA, "en");
await setLang(tabB, "en");
await loginViaUi(tabA, "admin", "admin123");
await Promise.all([tabA.goto("/"), tabB.goto("/")]);
const alertA = tabA.getByTestId("upcoming-meeting-alert");
const alertB = tabB.getByTestId("upcoming-meeting-alert");
await expect(alertA).toBeVisible({ timeout: 15_000 });
await expect(alertB).toBeVisible({ timeout: 15_000 });
// Dismiss in tabA → server records dismissed=true → emits the
// per-user event → tabB's query is invalidated and the alert hides.
await tabA.getByTestId("alert-close").click();
await expect(alertA).toBeHidden({ timeout: 5_000 });
await expect(alertB).toBeHidden({ timeout: 5_000 });
} finally {
await ctx.close();
}
expect(m.id).toBeGreaterThan(0);
});
test("Upcoming-meeting alert: realtime — Done by user A does NOT hide user B's alert", async ({
browser,
}) => {
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} rt-isolation AR`,
titleEn: `${TEST_TAG} rt-isolation EN`,
deltaMins: 3,
});
// Provision a second admin user via the admin API. Using a unique
// username per test run keeps reruns clean even if afterAll cleanup
// is preempted by a failure.
const otherUsername = `${TEST_TAG.toLowerCase()}_rt_other_${Date.now()}`;
const otherPassword = "Other-User-123!";
const adminCtx = await browser.newContext();
let otherUserId = null;
try {
const adminPage = await adminCtx.newPage();
await loginViaUi(adminPage, "admin", "admin123");
const createRes = await adminCtx.request.post("/api/users", {
data: {
username: otherUsername,
password: otherPassword,
email: `${otherUsername}@example.test`,
displayNameEn: "Realtime Isolation User",
displayNameAr: "مستخدم اختبار العزل",
preferredLanguage: "en",
},
});
expect(createRes.ok()).toBeTruthy();
const createBody = await createRes.json();
otherUserId = createBody?.id;
expect(typeof otherUserId).toBe("number");
// Grant the admin role (which is in EXECUTIVE_READ_ROLES) so
// requireExecutiveAccess passes for this synthetic user.
const grantRes = await adminCtx.request.post(
`/api/users/${otherUserId}/roles`,
{ data: { roleName: "admin" } },
);
expect(grantRes.ok()).toBeTruthy();
} finally {
await adminCtx.close();
}
// Two fresh contexts: one for `admin`, one for the just-created user.
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
try {
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
await setLang(pageA, "en");
await setLang(pageB, "en");
await loginViaUi(pageA, "admin", "admin123");
await loginViaUi(pageB, otherUsername, otherPassword);
await Promise.all([pageA.goto("/"), pageB.goto("/")]);
const alertA = pageA.getByTestId("upcoming-meeting-alert");
const alertB = pageB.getByTestId("upcoming-meeting-alert");
await expect(alertA).toBeVisible({ timeout: 15_000 });
await expect(alertB).toBeVisible({ timeout: 15_000 });
// User A acks. Their alert should clear within ~5s.
await pageA.getByTestId("alert-done").click();
await expect(alertA).toBeHidden({ timeout: 5_000 });
// User B must NOT receive the per-user event. We give the socket
// round-trip a generous head start, then assert B's alert is still
// visible. (The 30s poll wouldn't fire in this window either, so a
// hide here would mean the server leaked the event globally.)
await pageB.waitForTimeout(3_000);
await expect(alertB).toBeVisible();
} finally {
await ctxA.close();
await ctxB.close();
if (otherUserId != null) {
// Best-effort cleanup. The synthetic user touches several tables
// (role grant, group membership via Everyone, alert ack, audit
// logs from user creation). We delete in FK-safe order and just
// deactivate the user if FK rows remain — the user is namespaced
// by Date.now() so leftovers don't collide between runs.
try {
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [
otherUserId,
]);
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [
otherUserId,
]);
await pool.query(
`DELETE FROM executive_meeting_alert_state WHERE user_id = $1`,
[otherUserId],
);
await pool.query(
`DELETE FROM audit_logs WHERE actor_user_id = $1`,
[otherUserId],
);
await pool.query(`DELETE FROM users WHERE id = $1`, [otherUserId]);
} catch {
await pool
.query(`UPDATE users SET is_active = false WHERE id = $1`, [
otherUserId,
])
.catch(() => {});
}
}
}
expect(m.id).toBeGreaterThan(0);
});