Task #288: Share Executive Meetings row highlight colors across devices

Per-row highlight colors on the Executive Meetings daily schedule were
stored in each browser's localStorage, so a color set by the admin on
their laptop never reached the executive office or the big-screen
viewer. Move the color into a shared, server-stored field on the
meeting row so every viewer sees the same color in real time.

Schema
- Add nullable `row_color varchar(16)` to `executive_meetings`
  (lib/db/src/schema/executive-meetings.ts). Migration applied via
  `pnpm --filter @workspace/db push`.

API (artifacts/api-server/src/routes/executive-meetings.ts)
- Whitelist ROW_COLOR_KEYS = red/amber/green/blue/violet/gray.
- Extend meetingPatchSchema with optional `rowColor` (nullable enum).
- Existing PATCH /executive-meetings/:id picks it up, stamps
  updatedBy, writes the standard audit-log entry, and fires
  emitExecutiveMeetingsDaysChanged so other viewers' day query
  invalidates and re-fetches.
- Permission gating unchanged: requireMutate → executive_viewer 403.

Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx)
- `rowColors` is now derived from the meetings query via useMemo.
  The localStorage write effect is removed.
- New async setRowColor() PATCHes the server and invalidates the day
  query (key ["/api/executive-meetings", date]).
- Best-effort migration of legacy localStorage colors:
  - drops invalid keys / unknown colors immediately,
  - skips ids the server already colored (no overwrite),
  - PATCHes ids in the currently loaded day,
  - PRESERVES ids for dates the user hasn't visited yet so they
    migrate when they do navigate there (architect review caught a
    data-loss bug in the first cut where unvisited-day entries were
    being deleted),
  - keeps failed PATCHes for retry, removes the localStorage key
    only once nothing is left,
  - in-flight Set prevents double-PATCH if the effect re-fires.

Tests
- New artifacts/api-server/tests/executive-meetings-row-color.test.mjs
  (6 specs) covers PATCH set / clear / invalid-key 400 / viewer 403 /
  realtime executive_meetings_changed socket event for the affected
  date / audit attribution. All pass.
- E2E (runTest) verified two browser contexts share the color
  without manual refresh.

Documentation
- replit.md: added "Task #288 — Shared row colours" section.

Drift / notes
- Pre-existing TS errors in admin.tsx and pre-existing isHighlighted /
  font_settings scope errors in executive-meetings.ts are unrelated
  to this change.
- Follow-up #293 proposed: add a DB-level CHECK constraint mirroring
  the API whitelist for defense-in-depth.
This commit is contained in:
Riyadh
2026-05-01 15:32:38 +00:00
parent e74b0e6642
commit 8d230ca47c
@@ -19,6 +19,7 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
import { io as ioClient } from "socket.io-client";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
@@ -306,6 +307,68 @@ test("PATCH rowColor: non-mutate viewer role gets 403 and the row colour is unch
);
});
test("PATCH rowColor: the realtime executive_meetings_changed event fires for the affected date so other tabs / devices know to re-fetch", async () => {
// This is the contract that makes "shared" actually feel realtime —
// without it, a second viewer would only see the colour change after
// they manually refreshed. The frontend listens for this exact event
// (see use-notifications-socket.ts) and invalidates the day query.
const meeting = await createMeeting(
"RowColor socket",
"بث اللون عبر السوكيت",
);
const events = [];
const socket = ioClient(API_BASE, {
path: "/api/socket.io",
transports: ["websocket"],
forceNew: true,
reconnection: false,
extraHeaders: { Cookie: adminCookie },
});
await new Promise((resolve, reject) => {
socket.on("executive_meetings_changed", (payload) => {
events.push(payload);
});
socket.on("connect", resolve);
socket.on("connect_error", reject);
});
try {
const res = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{ rowColor: "gray" },
);
assert.equal(res.status, 200);
// Give the broadcast a moment to land. 400ms is plenty for a
// localhost socket and well under the test timeout.
const start = Date.now();
while (events.length === 0 && Date.now() - start < 400) {
await new Promise((r) => setTimeout(r, 25));
}
assert.ok(
events.length > 0,
"executive_meetings_changed should fire after a rowColor PATCH",
);
// Some payloads also include an array of dates; we just need the
// current day to be addressed in any of them.
const sawToday = events.some((p) => {
if (!p) return false;
if (typeof p.date === "string" && p.date === today) return true;
if (Array.isArray(p.dates) && p.dates.includes(today)) return true;
return false;
});
assert.ok(
sawToday,
`event should reference today's date (${today}); got ${JSON.stringify(events)}`,
);
} finally {
socket.disconnect();
}
});
test("PATCH rowColor: each successful change writes an audit row attributed to the acting user", async () => {
const meeting = await createMeeting("RowColor audit", "سجل لون الصف");