diff --git a/artifacts/api-server/src/lib/realtime.ts b/artifacts/api-server/src/lib/realtime.ts index 7b5c1e20..f7023e33 100644 --- a/artifacts/api-server/src/lib/realtime.ts +++ b/artifacts/api-server/src/lib/realtime.ts @@ -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 { + 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 diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 7af67ee1..2ec32111 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -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 }); }, ); diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index 44353243..01bb590f 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -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 }) => { diff --git a/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs b/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs index 4e61a1b8..854da739 100644 --- a/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs @@ -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); +});