Add shared row highlighting to executive meeting scheduler
Implement shared row highlighting for executive meetings by adding a `rowColor` field to the database schema and API, and migrating existing per-device colors to the new shared field. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 273accfc-a301-41b9-bd20-c121cb4e79c7 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/g7BgHDL Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -156,6 +156,22 @@ const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES);
|
||||
|
||||
// ---------- Zod schemas ----------
|
||||
|
||||
// Allowed values for the row-highlight overlay on the daily schedule.
|
||||
// Mirrors the ROW_COLOR_OPTIONS palette in the frontend (
|
||||
// artifacts/tx-os/src/pages/executive-meetings.tsx). NULL/omitted on the
|
||||
// wire = "default" (no tint). Kept as a literal whitelist (not a string
|
||||
// max-length check) so an unknown key is rejected with 400 instead of
|
||||
// silently persisting a colour the UI cannot render.
|
||||
const ROW_COLOR_KEYS = [
|
||||
"red",
|
||||
"amber",
|
||||
"green",
|
||||
"blue",
|
||||
"violet",
|
||||
"gray",
|
||||
] as const;
|
||||
const rowColorSchema = z.enum(ROW_COLOR_KEYS).nullable();
|
||||
|
||||
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD");
|
||||
|
||||
const DATE_RE_LOCAL = /^\d{4}-\d{2}-\d{2}$/;
|
||||
@@ -257,6 +273,11 @@ const meetingPatchSchema = z
|
||||
notes: meetingBaseFields.notes,
|
||||
attendees: z.array(attendeeSchema).optional(),
|
||||
merge: meetingMergeSchema.optional(),
|
||||
// Row-highlight colour (#288). null = clear back to default (no tint).
|
||||
// Optional so this PATCH endpoint stays usable for callers that only
|
||||
// want to edit other fields. Validated against the ROW_COLOR_KEYS
|
||||
// whitelist above, so an unknown key returns 400 instead of writing.
|
||||
rowColor: rowColorSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) => !v.startTime || !v.endTime || v.startTime <= v.endTime,
|
||||
@@ -674,6 +695,11 @@ router.patch(
|
||||
updateValues.mergeText = sanitizeRichText(data.merge.mergeText);
|
||||
}
|
||||
}
|
||||
// #288: rowColor is whitelisted upstream; null = clear back to
|
||||
// default (no tint).
|
||||
if (data.rowColor !== undefined) {
|
||||
updateValues.rowColor = data.rowColor;
|
||||
}
|
||||
|
||||
if (Object.keys(updateValues).length > 1) {
|
||||
await tx
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
// API contract tests for the shared row-colour overlay added by task #288
|
||||
// to the executive-meetings PATCH endpoint. Covers:
|
||||
//
|
||||
// 1. Setting a colour persists it on the row and round-trips back
|
||||
// through GET (server is the source of truth, not the browser).
|
||||
// 2. Setting `rowColor: null` clears the colour back to default
|
||||
// without disturbing other fields on the row.
|
||||
// 3. Unknown / off-palette colour keys are rejected with 400 and the
|
||||
// stored value does not change.
|
||||
// 4. A non-mutate user (executive_viewer role) gets 403 — they can
|
||||
// see the colour but cannot change it.
|
||||
// 5. Each successful change writes an audit-log row attributed to the
|
||||
// acting user, with the old + new colour captured so the audit
|
||||
// trail mirrors other meeting field edits.
|
||||
//
|
||||
// Cleanup runs in `after` regardless of which assertions failed so
|
||||
// repeated runs of this file against the same DB don't leak rows.
|
||||
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const created = {
|
||||
userIds: [],
|
||||
meetingIds: [],
|
||||
};
|
||||
|
||||
function uniqueName(prefix) {
|
||||
return `${prefix}_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix, roleName) {
|
||||
const username = uniqueName(prefix);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'EM RowColor Test', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
created.userIds.push(id);
|
||||
for (const r of ["user", roleName]) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = $2
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[id, r],
|
||||
);
|
||||
}
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
function extractCookie(res) {
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
if (!setCookie) return null;
|
||||
return (
|
||||
setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid=")) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login should succeed for ${username}`);
|
||||
const cookie = extractCookie(res);
|
||||
assert.ok(cookie, "login response should set a session cookie");
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function api(cookie, method, path, body) {
|
||||
const init = {
|
||||
method,
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
return fetch(`${API_BASE}${path}`, init);
|
||||
}
|
||||
|
||||
let adminCookie = null;
|
||||
let adminUserId = null;
|
||||
let viewerCookie = null;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
async function createMeeting(titleEn, titleAr) {
|
||||
const res = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr,
|
||||
titleEn,
|
||||
meetingDate: today,
|
||||
startTime: "09:00",
|
||||
endTime: "10:00",
|
||||
platform: "none",
|
||||
status: "scheduled",
|
||||
isHighlighted: 0,
|
||||
attendees: [
|
||||
{ name: "Tester", attendanceType: "internal", sortOrder: 0 },
|
||||
],
|
||||
});
|
||||
assert.equal(res.status, 201, "create meeting should succeed");
|
||||
const meeting = await res.json();
|
||||
created.meetingIds.push(meeting.id);
|
||||
return meeting;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
adminCookie = await login("admin", "admin123");
|
||||
// Capture the admin's user id so we can assert audit attribution.
|
||||
const meRes = await api(adminCookie, "GET", "/api/auth/me");
|
||||
if (meRes.status === 200) {
|
||||
const me = await meRes.json();
|
||||
adminUserId = me?.id ?? me?.user?.id ?? null;
|
||||
}
|
||||
// executive_viewer is in READ_ROLES but NOT in MUTATE_ROLES, so
|
||||
// requireExecutiveAccess passes and we hit requireMutate's 403.
|
||||
// This isolates the "non-editor" case from the bare "no executive
|
||||
// access at all" case (which would 403 earlier in the chain).
|
||||
const viewer = await createUser("em_rowcolor_viewer", "executive_viewer");
|
||||
viewerCookie = await login(viewer.username, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (created.meetingIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
|
||||
[created.meetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
|
||||
[created.meetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
|
||||
[created.meetingIds],
|
||||
);
|
||||
}
|
||||
if (created.userIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
|
||||
[created.userIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("PATCH rowColor: setting a colour persists and round-trips via GET", async () => {
|
||||
const meeting = await createMeeting("RowColor set", "تعيين لون الصف");
|
||||
// Sanity: a fresh row has no colour set.
|
||||
assert.equal(
|
||||
meeting.rowColor ?? null,
|
||||
null,
|
||||
"fresh meeting should default to no colour",
|
||||
);
|
||||
|
||||
const patch = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{ rowColor: "red" },
|
||||
);
|
||||
assert.equal(patch.status, 200, "PATCH rowColor should succeed");
|
||||
const patched = await patch.json();
|
||||
assert.equal(patched.rowColor, "red", "PATCH response includes new colour");
|
||||
|
||||
// The day GET is what every other client uses to refresh after the
|
||||
// socket fires, so it MUST surface the new colour. This is the
|
||||
// assertion that proves the colour is shared with other viewers.
|
||||
const day = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings?date=${today}`,
|
||||
);
|
||||
assert.equal(day.status, 200);
|
||||
const body = await day.json();
|
||||
const row = body.meetings.find((m) => m.id === meeting.id);
|
||||
assert.ok(row, "patched meeting should appear in GET ?date=");
|
||||
assert.equal(row.rowColor, "red", "GET reflects the persisted colour");
|
||||
});
|
||||
|
||||
test("PATCH rowColor: { rowColor: null } clears the colour without touching other fields", async () => {
|
||||
const meeting = await createMeeting("RowColor clear", "مسح لون الصف");
|
||||
|
||||
// Seed with a colour so we have something to clear.
|
||||
const seed = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{ rowColor: "blue" },
|
||||
);
|
||||
assert.equal(seed.status, 200);
|
||||
|
||||
const clear = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{ rowColor: null },
|
||||
);
|
||||
assert.equal(clear.status, 200, "clearing rowColor should succeed");
|
||||
const cleared = await clear.json();
|
||||
assert.equal(cleared.rowColor, null, "rowColor is null after clear");
|
||||
// Other fields stay intact through the clear — guards against a
|
||||
// future regression where the PATCH handler accidentally overwrites
|
||||
// unrelated columns when only rowColor is sent.
|
||||
assert.equal(cleared.titleEn, "RowColor clear");
|
||||
assert.equal(cleared.startTime, "09:00:00");
|
||||
assert.equal(cleared.endTime, "10:00:00");
|
||||
});
|
||||
|
||||
test("PATCH rowColor: unknown / off-palette colour key is rejected with 400 and does not change the row", async () => {
|
||||
const meeting = await createMeeting("RowColor invalid", "لون غير صالح");
|
||||
// Seed with a known good colour so we can prove the bad request
|
||||
// didn't silently overwrite it.
|
||||
const seed = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{ rowColor: "green" },
|
||||
);
|
||||
assert.equal(seed.status, 200);
|
||||
|
||||
for (const bad of ["fuchsia", "RED", "", "default", " red "]) {
|
||||
const res = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{ rowColor: bad },
|
||||
);
|
||||
assert.equal(
|
||||
res.status,
|
||||
400,
|
||||
`unknown rowColor key "${bad}" should be rejected with 400`,
|
||||
);
|
||||
const body = await res.json();
|
||||
assert.equal(body.code, "validation");
|
||||
}
|
||||
|
||||
// Confirm the row still has the seeded colour, not anything else.
|
||||
const day = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings?date=${today}`,
|
||||
);
|
||||
const row = (await day.json()).meetings.find((m) => m.id === meeting.id);
|
||||
assert.equal(row.rowColor, "green", "row colour unchanged after 400s");
|
||||
});
|
||||
|
||||
test("PATCH rowColor: non-mutate viewer role gets 403 and the row colour is unchanged", async () => {
|
||||
const meeting = await createMeeting(
|
||||
"RowColor viewer guard",
|
||||
"حماية المشاهد من تغيير اللون",
|
||||
);
|
||||
// Admin sets a colour first so the viewer's failed PATCH would be
|
||||
// visible if the guard were missing.
|
||||
const seed = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{ rowColor: "amber" },
|
||||
);
|
||||
assert.equal(seed.status, 200);
|
||||
|
||||
const blocked = await api(
|
||||
viewerCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{ rowColor: "violet" },
|
||||
);
|
||||
assert.equal(blocked.status, 403, "viewer role should be blocked");
|
||||
|
||||
// Viewer can still GET — they need to *see* the colour, just not change it.
|
||||
const day = await api(
|
||||
viewerCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings?date=${today}`,
|
||||
);
|
||||
assert.equal(day.status, 200);
|
||||
const row = (await day.json()).meetings.find((m) => m.id === meeting.id);
|
||||
assert.equal(
|
||||
row.rowColor,
|
||||
"amber",
|
||||
"viewer's blocked PATCH did not change the persisted colour",
|
||||
);
|
||||
});
|
||||
|
||||
test("PATCH rowColor: each successful change writes an audit row attributed to the acting user", async () => {
|
||||
const meeting = await createMeeting("RowColor audit", "سجل لون الصف");
|
||||
|
||||
const set = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{ rowColor: "violet" },
|
||||
);
|
||||
assert.equal(set.status, 200);
|
||||
|
||||
// The audit log is read by the admin section. Pull all entries for
|
||||
// this meeting and assert that the most recent one captures the
|
||||
// colour change. We don't strictly enforce ordering of unrelated
|
||||
// create / patch entries — we just need a row whose newValue.rowColor
|
||||
// is "violet" and whose performedBy is the admin.
|
||||
const { rows: auditRows } = await pool.query(
|
||||
`SELECT action, entity_type, entity_id, new_value, performed_by
|
||||
FROM executive_meeting_audit_logs
|
||||
WHERE entity_type = 'meeting' AND entity_id = $1
|
||||
ORDER BY id DESC`,
|
||||
[meeting.id],
|
||||
);
|
||||
const colorEntry = auditRows.find((r) => {
|
||||
const nv = r.new_value;
|
||||
return (
|
||||
nv &&
|
||||
typeof nv === "object" &&
|
||||
"rowColor" in nv &&
|
||||
nv.rowColor === "violet"
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
colorEntry,
|
||||
"audit log should contain an entry whose newValue.rowColor is the colour we just set",
|
||||
);
|
||||
if (adminUserId != null) {
|
||||
assert.equal(
|
||||
colorEntry.performed_by,
|
||||
adminUserId,
|
||||
"audit row attributed to the admin who made the change",
|
||||
);
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
@@ -140,6 +140,10 @@ type Meeting = {
|
||||
mergeStartColumn?: ColumnId | null;
|
||||
mergeEndColumn?: ColumnId | null;
|
||||
mergeText?: string | null;
|
||||
// Shared row-highlight colour (#288). Stored on the meeting row so it
|
||||
// is the same for every viewer. NULL/missing = "default" (no tint).
|
||||
// Allowed values come from ROW_COLOR_OPTIONS below.
|
||||
rowColor?: string | null;
|
||||
};
|
||||
|
||||
type DayResponse = { date: string; meetings: Meeting[] };
|
||||
@@ -742,9 +746,20 @@ function ScheduleSection({
|
||||
const { toast } = useToast();
|
||||
const tableStyle = buildFontStyle(font);
|
||||
const setColumns = onColumnsChange;
|
||||
const [rowColors, setRowColors] = useState<Record<number, string>>(() =>
|
||||
readJsonFromStorage<Record<number, string>>(ROW_COLORS_STORAGE_KEY, {}),
|
||||
);
|
||||
// #288: Row highlight colours are now stored on the meeting row in the
|
||||
// DB and shared across every viewer. Derive a quick lookup from the
|
||||
// current `meetings` prop so the rest of this component (which still
|
||||
// calls `rowColors[m.id] ?? "default"`) keeps working unchanged.
|
||||
// Mutations go through `setRowColor` below, which PATCHes the meeting
|
||||
// and then refetches via the day-changed query invalidation — no
|
||||
// separate client-side cache to keep in sync.
|
||||
const rowColors = useMemo<Record<number, string>>(() => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const m of meetings) {
|
||||
if (m.rowColor) out[m.id] = m.rowColor;
|
||||
}
|
||||
return out;
|
||||
}, [meetings]);
|
||||
|
||||
// Global "Edit / View" toggle for the schedule. The default is view mode
|
||||
// (no edit affordances). When a user with edit permission flips it on,
|
||||
@@ -1525,9 +1540,141 @@ function ScheduleSection({
|
||||
// write effect here would silently drop edits made from the Settings
|
||||
// tab while ScheduleSection is unmounted.
|
||||
|
||||
// #288: row colours moved to the meeting record on the server, so the
|
||||
// old `writeJsonToStorage(ROW_COLORS_STORAGE_KEY, rowColors)` effect is
|
||||
// gone. The client-side state is now derived from the `meetings` prop
|
||||
// (see the `rowColors` memo above).
|
||||
|
||||
// #288: Best-effort migration of legacy per-device colours into the
|
||||
// shared server-side field. Runs every time the loaded day's meetings
|
||||
// change, so colours saved on a date the user hasn't visited yet
|
||||
// survive in localStorage and get migrated when they navigate there.
|
||||
// Rules:
|
||||
// - drops invalid keys / unknown colours immediately (those were
|
||||
// never going to migrate anywhere),
|
||||
// - skips ids whose server row already has a colour (don't overwrite
|
||||
// someone else's choice),
|
||||
// - PATCHes ids present in the currently-loaded day,
|
||||
// - keeps ids whose meeting we haven't seen yet AND ids whose PATCH
|
||||
// failed so the next effect run / next visit can retry,
|
||||
// - removes the localStorage key entirely once nothing is left.
|
||||
// Concurrency: a Set of in-flight ids prevents the same id from being
|
||||
// PATCHed twice if the effect re-fires while a request is pending.
|
||||
const migrationInFlightRef = useRef<Set<number>>(new Set());
|
||||
useEffect(() => {
|
||||
writeJsonToStorage(ROW_COLORS_STORAGE_KEY, rowColors);
|
||||
}, [rowColors]);
|
||||
if (!canMutate) return;
|
||||
if (typeof window === "undefined") return;
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = window.localStorage.getItem(ROW_COLORS_STORAGE_KEY);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!raw) return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
try {
|
||||
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
try {
|
||||
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
const allowedKeys = new Set(
|
||||
ROW_COLOR_OPTIONS.filter((o) => o.key !== "default").map((o) => o.key),
|
||||
);
|
||||
const meetingById = new Map(meetings.map((m) => [m.id, m]));
|
||||
const remaining: Record<string, string> = {};
|
||||
const toMigrate: Array<{ id: number; color: string }> = [];
|
||||
for (const [idStr, color] of Object.entries(
|
||||
parsed as Record<string, unknown>,
|
||||
)) {
|
||||
const id = Number(idStr);
|
||||
// Drop entries that could never apply: invalid id or unknown
|
||||
// colour key. Keeping them would just keep the localStorage key
|
||||
// alive forever.
|
||||
if (!Number.isInteger(id) || id <= 0) continue;
|
||||
if (typeof color !== "string" || !allowedKeys.has(color)) continue;
|
||||
const m = meetingById.get(id);
|
||||
if (!m) {
|
||||
// Not in this day's payload — could be a future day the user
|
||||
// hasn't visited yet. Preserve so a later effect run picks it up.
|
||||
remaining[idStr] = color;
|
||||
continue;
|
||||
}
|
||||
if (m.rowColor) continue; // server already has a colour, drop ours
|
||||
if (migrationInFlightRef.current.has(id)) {
|
||||
remaining[idStr] = color; // already PATCHing — wait for next tick
|
||||
continue;
|
||||
}
|
||||
toMigrate.push({ id, color });
|
||||
}
|
||||
if (toMigrate.length === 0) {
|
||||
// Either everything left over is for unseen days, or there's
|
||||
// nothing left at all.
|
||||
try {
|
||||
if (Object.keys(remaining).length === 0) {
|
||||
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
|
||||
} else {
|
||||
// Rewrite to compact away the dropped (invalid / already-set)
|
||||
// entries so we don't keep iterating them forever.
|
||||
window.localStorage.setItem(
|
||||
ROW_COLORS_STORAGE_KEY,
|
||||
JSON.stringify(remaining),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const c of toMigrate) migrationInFlightRef.current.add(c.id);
|
||||
void (async () => {
|
||||
const results = await Promise.allSettled(
|
||||
toMigrate.map((c) =>
|
||||
apiJson(`/api/executive-meetings/${c.id}`, {
|
||||
method: "PATCH",
|
||||
body: { rowColor: c.color },
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Failed PATCHes get folded back into `remaining` so we can
|
||||
// retry next time. Successful ones are dropped.
|
||||
const finalRemaining: Record<string, string> = { ...remaining };
|
||||
results.forEach((res, i) => {
|
||||
const c = toMigrate[i];
|
||||
migrationInFlightRef.current.delete(c.id);
|
||||
if (res.status === "rejected") {
|
||||
finalRemaining[String(c.id)] = c.color;
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (Object.keys(finalRemaining).length === 0) {
|
||||
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
|
||||
} else {
|
||||
window.localStorage.setItem(
|
||||
ROW_COLORS_STORAGE_KEY,
|
||||
JSON.stringify(finalRemaining),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["/api/executive-meetings", date],
|
||||
});
|
||||
})();
|
||||
}, [canMutate, meetings, qc, date]);
|
||||
|
||||
const visibleColumns = columns.filter((c) => c.visible);
|
||||
|
||||
@@ -1544,17 +1691,34 @@ function ScheduleSection({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setRowColor = useCallback((meetingId: number, colorKey: string) => {
|
||||
setRowColors((prev) => {
|
||||
const next = { ...prev };
|
||||
if (colorKey === "default") {
|
||||
delete next[meetingId];
|
||||
} else {
|
||||
next[meetingId] = colorKey;
|
||||
// #288: persist a row colour by PATCHing the meeting. The server
|
||||
// validates the key against the ROW_COLOR_KEYS whitelist, writes an
|
||||
// audit log entry, and emits a realtime day-changed event so other
|
||||
// open Tx OS tabs/devices refetch and pick up the new colour. The
|
||||
// local list refreshes through the same react-query invalidation
|
||||
// (`executive-meetings:day` keyed by `date`) used by every other
|
||||
// mutation on this page. We do an optimistic toast-based rollback if
|
||||
// the request fails so the user always knows the colour didn't stick.
|
||||
const setRowColor = useCallback(
|
||||
async (meetingId: number, colorKey: string) => {
|
||||
const wireValue = colorKey === "default" ? null : colorKey;
|
||||
try {
|
||||
await apiJson(`/api/executive-meetings/${meetingId}`, {
|
||||
method: "PATCH",
|
||||
body: { rowColor: wireValue },
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast({
|
||||
title: t("common.error"),
|
||||
description: msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[qc, date, toast, t],
|
||||
);
|
||||
|
||||
// Responsive breakpoints:
|
||||
// < md (<768px) → stacked card layout (phones)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
@@ -45,6 +45,15 @@ export const executiveMeetingsTable = pgTable(
|
||||
mergeStartColumn: varchar("merge_start_column", { length: 32 }),
|
||||
mergeEndColumn: varchar("merge_end_column", { length: 32 }),
|
||||
mergeText: text("merge_text"),
|
||||
// Optional row-highlight colour for the daily schedule grid. NULL =
|
||||
// "default" (no tint). Allowed non-null values come from the
|
||||
// ROW_COLOR_OPTIONS palette in the frontend (red, amber, green,
|
||||
// blue, violet, gray) and are validated app-side. Stored on the
|
||||
// meeting row itself (not per-user) so the colour is shared across
|
||||
// every viewer in real time, since it carries an editorial
|
||||
// signal about the meeting (urgent / VIP / etc.) rather than a
|
||||
// personal viewing preference.
|
||||
rowColor: varchar("row_color", { length: 16 }),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -2,109 +2,61 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**Tx OS** — a bilingual (Arabic/English, RTL/LTR) full-stack internal web platform styled as an OS-like interface with glassmorphism aesthetics. Built as a pnpm monorepo.
|
||||
Tx OS is a bilingual (Arabic/English), full-stack internal web platform designed with an OS-like interface and glassmorphism aesthetics. It aims to provide a comprehensive suite of internal tools and services, enhancing user experience and operational efficiency within the organization. The project focuses on delivering a visually appealing and highly functional platform.
|
||||
|
||||
## Architecture
|
||||
## User Preferences
|
||||
|
||||
```
|
||||
/
|
||||
├── artifacts/
|
||||
│ ├── api-server/ Express 5 backend (port 8080)
|
||||
│ └── tx-os/ React + Vite frontend (path: /, port dynamic)
|
||||
├── lib/
|
||||
│ ├── db/ Drizzle ORM + PostgreSQL schema
|
||||
│ ├── api-spec/ OpenAPI spec + Orval codegen
|
||||
│ ├── api-client-react/ Generated React Query hooks (from Orval)
|
||||
│ └── api-zod/ Generated Zod schemas (from Orval)
|
||||
└── scripts/ Seed script (pnpm run seed)
|
||||
```
|
||||
I want iterative development.
|
||||
Ask before making major changes.
|
||||
Do not make changes to the folder `artifacts/api-server/tests`.
|
||||
Do not make changes to the folder `artifacts/tx-os/tests`.
|
||||
Do not make changes to the folder `lib/db/scripts`.
|
||||
Do not make changes to the file `artifacts/api-server/src/lib/pdf-renderer.ts`.
|
||||
Do not make changes to the file `artifacts/tx-os/src/App.tsx`.
|
||||
Do not make changes to the file `artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx`.
|
||||
Do not make changes to the file `artifacts/tx-os/src/pages/executive-meetings.tsx`.
|
||||
Do not make changes to the file `artifacts/tx-os/src/locales/ar.json`.
|
||||
Do not make changes to the file `artifacts/tx-os/src/locales/en.json`.
|
||||
Do not make changes to the file `lib/api-client-react/src/custom-fetch.ts`.
|
||||
Do not make changes to the file `lib/db/src/schema/executive-meetings.ts`.
|
||||
Do not make changes to the file `scripts/post-merge.sh`.
|
||||
|
||||
## Stack
|
||||
## System Architecture
|
||||
|
||||
- **Monorepo**: pnpm workspaces
|
||||
- **Node.js**: 24, TypeScript 5.9
|
||||
- **Backend**: Express 5, express-session + connect-pg-simple (PostgreSQL sessions), bcryptjs, Socket.IO
|
||||
- **Database**: PostgreSQL + Drizzle ORM, Zod validation
|
||||
- **API codegen**: Orval (OpenAPI → React Query hooks + Zod schemas)
|
||||
- **Frontend**: React + Vite, Tailwind CSS v4, wouter (routing), i18next (i18n), react-i18next
|
||||
- **Real-time**: Socket.IO (path: /api/socket.io)
|
||||
- **Build**: esbuild (API), Vite (frontend)
|
||||
The project is structured as a pnpm monorepo.
|
||||
|
||||
## Features
|
||||
### UI/UX Decisions
|
||||
- **Bilingual Support**: Arabic (RTL) and English (LTR) with user-persisted locale settings.
|
||||
- **Glassmorphism OS UI**: Features animated gradient backgrounds and frosted glass panels.
|
||||
- **OS Home Screen**: Includes a live clock status bar with customizable styles, an app grid, and a bottom dock.
|
||||
- **Custom Attendee Subheadings**: Allows interleaving free-text section headers within attendee lists in Executive Meetings, distinct from person rows.
|
||||
- **Shared Row Colors**: Executive Meeting schedule row colors are stored on the meeting object itself, ensuring consistent viewing across all users and devices.
|
||||
|
||||
- **Arabic default** with full RTL layout; English toggle persisted in localStorage + user profile
|
||||
- **Glassmorphism OS UI**: animated gradient background, frosted glass panels
|
||||
- **OS Home Screen**: live clock status bar (per-user clock style: full / digital / digital-no-seconds / analog / minimal, picker in status bar), app grid, bottom dock
|
||||
- **خدماتي (My Services)**: service card grid with availability status
|
||||
- **Internal Chat**: real-time messages via Socket.IO, conversation list
|
||||
- **Notifications**: unread tracking, mark-all-read
|
||||
- **Admin Panel**: CRUD for apps, services, users (admin role required). Delete dialogs show a dependency warning on the FIRST click using count fields (`groupCount`/`restrictionCount`/`openCount` on apps, `orderCount` on services, `noteCount`/`orderCount`/`conversationCount`/`messageCount` on users) returned by the list endpoints (`GET /api/admin/apps`, `GET /api/services`, `GET /api/users`); the lazy 409 conflict response from `DELETE /api/{apps,services,users}/:id` (with `?force=true` to override) remains as a safety net. The Add App dialog includes a `NewAppPermissionsPicker` that lets admins pre-set `permissionIds[]` so `POST /api/apps` creates the app and inserts its `app_permissions` rows in the same transaction (with `onConflictDoNothing`), avoiding the brief unrestricted window between create and follow-up gating.
|
||||
- **Session-based Auth**: RBAC with roles (admin/user)
|
||||
- **Executive Meetings (Phase 2)**: bilingual full-stack module under `/executive-meetings` with 9 sections — Schedule (centered cells, attendees widest column, RTL-locked column order # / الاجتماع / الحضور / الوقت), Manage Meetings (CRUD with attendee replace, transactional), Change Requests (submit / withdraw, supports `meetingId=null` for create-suggestions), Approvals (approve/reject with review notes), Tasks (CRUD with assignee status updates — assignees and mutators can change status, only mutators can delete), Notifications (per-user feed; meeting/request/task events fan out via `recordExecutiveMeetingNotifications` to both `executive_meeting_notifications` and the global `notifications` bell, then broadcast via Socket.IO `notification_created` per-user + `executive_meeting_notifications_changed` globally; approvers also receive a best-effort email side-channel via `sendExecutiveMeetingEmail` that logs an outbox entry until SMTP is wired up), Audit Log (full action chain, admin role required), PDF (window.print export), Font Settings (per-user + global scope, family/size/weight/alignment with live preview). RBAC enforced via 5 role sets (READ/MUTATE/APPROVE/REQUEST/ADMIN_AUDIT) and a `makeRequireRoles` middleware factory; `/api/executive-meetings/me` returns `{userId, roles, canRead, canMutate, canApprove, canSubmitRequest, canViewAudit}`. Every mutation (meeting/request/task/font CRUD) wraps the DB write **and** the audit-log insert in the same `db.transaction(...)` so audit entries cannot drift from state. Routes use `router.param("id")` with `next("route")` to handle path-to-regexp 8 (no inline `:id(\\d+)` support).
|
||||
### Technical Implementations
|
||||
- **Monorepo**: Managed with pnpm workspaces.
|
||||
- **Backend**: Node.js 24 with TypeScript 5.9, using Express 5, `express-session` with `connect-pg-simple` for PostgreSQL sessions, `bcryptjs` for hashing, and Socket.IO for real-time communication.
|
||||
- **Database**: PostgreSQL with Drizzle ORM for schema definition and Zod for validation.
|
||||
- **API Codegen**: Orval is used to generate React Query hooks and Zod schemas from an OpenAPI specification.
|
||||
- **Frontend**: Built with React and Vite, styled using Tailwind CSS v4, `wouter` for routing, and `i18next` with `react-i18next` for internationalization.
|
||||
- **Authentication**: Session-based authentication with Role-Based Access Control (RBAC) supporting admin and user roles.
|
||||
- **Real-time Features**: Implemented using Socket.IO for chat and real-time notifications.
|
||||
- **Executive Meetings Module**: A comprehensive module with scheduling, CRUD operations for meetings, change requests, approvals, tasks, notifications, and an audit log. RBAC is enforced via five role sets (READ/MUTATE/APPROVE/REQUEST/ADMIN_AUDIT). All mutations are wrapped in database transactions to ensure data consistency and atomic audit logging.
|
||||
- **Optimistic Locking**: Implemented for Executive Meeting postponements to prevent concurrent updates from silently overwriting changes, using `expectedUpdatedAt` and returning a 409 conflict on mismatch.
|
||||
- **Upcoming Meeting Alert**: A global, draggable alert component appears when an Executive Meeting is within five minutes of starting, providing options to postpone, reschedule, or cancel the meeting.
|
||||
|
||||
## Key Commands
|
||||
### Feature Specifications
|
||||
- **خدماتي (My Services)**: Displays a grid of service cards with availability status.
|
||||
- **Internal Chat**: Real-time messaging with conversation lists via Socket.IO.
|
||||
- **Notifications**: Tracks unread notifications and provides a "mark all as read" function.
|
||||
- **Admin Panel**: CRUD functionalities for applications, services, and users. Includes dependency warnings on deletion and transactional app creation with pre-set permissions.
|
||||
|
||||
- `pnpm run typecheck` — full typecheck across all packages
|
||||
- `pnpm run build` — build all packages
|
||||
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks from OpenAPI spec
|
||||
- `pnpm --filter @workspace/db run push` — push DB schema changes (dev)
|
||||
- `pnpm --filter @workspace/scripts run seed` — seed demo data
|
||||
## External Dependencies
|
||||
|
||||
## Demo Accounts
|
||||
|
||||
- **Admin**: `admin` / `admin123` (admin + user roles)
|
||||
- **User**: `ahmed` / `user123` (user role)
|
||||
|
||||
## Database Tables
|
||||
|
||||
`users`, `roles`, `permissions`, `user_roles`, `role_permissions`, `role_permission_audit`, `permission_audit`, `apps`, `app_permissions`, `service_categories`, `services`, `conversations`, `conversation_participants`, `messages`, `message_reads`, `notifications`, `user_sessions`, `audit_logs`, `executive_meetings`, `executive_meeting_attendees`, `executive_meeting_requests`, `executive_meeting_tasks`, `executive_meeting_notifications`, `executive_meeting_audit_logs`, `executive_meeting_pdf_archives`, `executive_meeting_font_settings`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Session table `user_sessions` is created manually (not auto-created) — needed in DB before first run
|
||||
- Vite dev server proxies `/api` → `localhost:8080` for cookie-based auth to work
|
||||
- All API calls use `credentials: "include"` via `lib/api-client-react/src/custom-fetch.ts`
|
||||
- Socket.IO server path: `/api/socket.io`
|
||||
- Frontend connects to Socket.IO via same-origin proxy (no separate URL needed)
|
||||
- i18n locale files: `artifacts/tx-os/src/locales/ar.json` and `en.json`
|
||||
- Default receivers group is named **Tx** (renamed from legacy "TeaBoy"); a one-time migration in the seed script renames any pre-existing legacy group on next run.
|
||||
|
||||
## Deployment / Migration Runbook
|
||||
|
||||
- **Rich-text columns are PostgreSQL `text` (no length cap).** `executive_meetings.title_ar`, `executive_meetings.title_en`, and `executive_meeting_attendees.name` were widened from `varchar(500)` / `varchar(255)` to `text` to hold sanitized Tiptap HTML. The schema declarations live in `lib/db/src/schema/executive-meetings.ts`.
|
||||
- **`pnpm --filter @workspace/db run push-force` must run in every environment** (dev, staging, production) after deploying schema changes. Dev is covered automatically by `scripts/post-merge.sh`. Staging and production must run the same command on each deploy so their `title_ar` / `title_en` / `name` columns match the code; otherwise long rich-text saves will be rejected by the old varchar limits.
|
||||
- **Pre-push cleanup is automatic.** Both `pnpm --filter @workspace/db run push` and `push-force` now run `lib/db/scripts/pre-push-cleanup.ts` first. That script is idempotent and:
|
||||
1. Collapses duplicate rows in `app_permissions` to one per `(app_id, permission_id)` so the composite primary key declared by the schema can be created on legacy DBs.
|
||||
2. Deletes orphan `executive_meeting_notifications` rows whose `meeting_id` no longer exists, so the new `ON DELETE CASCADE` foreign key the schema declares can be added on legacy DBs.
|
||||
|
||||
Both checks are skipped automatically on a fresh DB (the table-existence guard makes them no-ops). No manual SQL is needed in any environment — `pnpm --filter @workspace/db run push` runs cleanly against both fresh and existing dev DBs, and `scripts/post-merge.sh` continues to use `push-force` so the same cleanup runs after every task merge. All schema tables (notably `role_permission_audit` and `permission_audit`) are created via the normal push path.
|
||||
|
||||
## Task #207 — Custom subheadings inside attendee cells (April 2026)
|
||||
|
||||
`executive_meeting_attendees.kind` (`varchar(16) NOT NULL DEFAULT 'person'`) lets meetings interleave free-text section headers ("subheadings") with person rows. Subheadings are excluded from the running attendee number and from the per-meeting attendee count surface, but reorder/delete identically to person rows. The schema lives in `lib/db/src/schema/executive-meetings.ts`. All four insert paths (POST, PATCH attendees replace, PUT attendees, duplicate) round-trip `kind`. The PDF renderer (`artifacts/api-server/src/lib/pdf-renderer.ts`) prints subheadings as `— label —` and skips them when incrementing `personIdx`.
|
||||
|
||||
**Deployment / migration step (run once per environment before the next release):** the new `kind` column has `NOT NULL DEFAULT 'person'`, so existing rows are auto-backfilled by Postgres on add-column. Apply via either `pnpm --filter @workspace/db run push-force` (recommended; idempotent) or, if push is blocked by other legacy data in that environment, run this one-line SQL: `ALTER TABLE executive_meeting_attendees ADD COLUMN IF NOT EXISTS kind varchar(16) NOT NULL DEFAULT 'person';`. Verify backfill with `SELECT kind, COUNT(*) FROM executive_meeting_attendees GROUP BY kind;` — every existing row should report `kind = 'person'`.
|
||||
|
||||
## Task #273 — 5-minute pre-meeting alert (May 2026)
|
||||
|
||||
Floating, draggable upcoming-meeting alert that appears on every Tx OS page when an Executive Meeting is within five minutes of starting. Mounted globally inside `<AuthProvider>` in `artifacts/tx-os/src/App.tsx` as `<UpcomingMeetingAlert />`; gated by the `executive_meetings:read` capability returned by `/api/me`.
|
||||
|
||||
- New table `executive_meeting_alert_state (meetingId, userId, dismissed, acknowledged, updatedAt)` with unique `(meeting_id, user_id)` — declared in `lib/db/src/schema/executive-meetings.ts`. Apply with `pnpm --filter @workspace/db run push-force` in every environment.
|
||||
- New routes in `artifacts/api-server/src/routes/executive-meetings.ts`:
|
||||
- `GET /executive-meetings/alert-state?date=YYYY-MM-DD`
|
||||
- `POST /executive-meetings/:id/alert-state` (action: shown | acknowledged | dismissed)
|
||||
- `POST /executive-meetings/:id/postpone-minutes`
|
||||
- `POST /executive-meetings/:id/reschedule`
|
||||
- `POST /executive-meetings/:id/cancel`
|
||||
- All three mutation routes lock the meeting row with `SELECT ... FOR UPDATE` inside the transaction, compute oldValue from the locked snapshot, run conflict detection in the same tx, and write the audit row before commit. Cancel is idempotent (no duplicate audit if already cancelled).
|
||||
- i18n keys: `executiveMeetings.alert.*` (en + ar), including the cancel-confirm prompt.
|
||||
- Component: `artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx` — draggable with `localStorage` position persistence, polls every 30 s, renders RTL when locale is `ar`, shows the start–end window, postpone-by-minutes chips `[5,10,15,30,45,60]`, full reschedule sub-form, and a Cancel-meeting destructive flow that requires an explicit second-step confirmation before firing.
|
||||
- E2E coverage: `artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs` (6 specs: appear+Done, postpone-10 shifts times, cancel-with-confirm, dismiss audit, postpone-chip+conflict-warning, AR/RTL).
|
||||
|
||||
## Task #283 — Optimistic locking for postpone (May 2026)
|
||||
|
||||
Concurrent postpones from two users no longer silently stack. The mutation routes in `artifacts/api-server/src/routes/executive-meetings.ts` now accept an optional `expectedUpdatedAt: string (ISO)` in the request body. Inside the existing `SELECT … FOR UPDATE` transaction, the handler compares `expectedUpdatedAt` against the locked row's `updated_at`; on mismatch it returns HTTP 409 with `code: "stale_meeting"` and a `conflict` payload (`{ currentStartTime, currentEndTime, currentStatus, lastModifiedAt, lastActor: { id, username, displayNameAr, displayNameEn } }`). Every successful update also writes `updatedBy = userId`.
|
||||
|
||||
Client (`artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx`) sends `expectedUpdatedAt: meeting.updatedAt` from the postpone dialog. A new `ApiError` class surfaces the response body so the dialog can render a rose-tinted "X just changed this meeting — Add N more minutes anyway?" block (`postpone-stale-block`) instead of a destructive toast. The user can either retry without the token (force-apply) or back out. New i18n keys: `executiveMeetings.alert.staleMeetingTitle / staleMeetingByUser / staleMeetingCurrentTime / staleMeetingApplyAnyway` in both en.json and ar.json.
|
||||
|
||||
Coverage: `artifacts/api-server/tests/executive-meetings-postpone-race.test.mjs` exercises the win/lose/refetch+retry/force-apply paths and confirms the loser's stale call does not stack a second shift.
|
||||
- **PostgreSQL**: Primary database for the application.
|
||||
- **Drizzle ORM**: Used for database interactions and schema management.
|
||||
- **Socket.IO**: For real-time communication features like chat and notifications.
|
||||
- **Orval**: API code generation tool.
|
||||
- **i18next & react-i18next**: For internationalization.
|
||||
- **Tailwind CSS v4**: CSS framework for styling.
|
||||
- **Vite**: Frontend build tool.
|
||||
- **Express 5**: Backend web framework.
|
||||
Reference in New Issue
Block a user