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