Task #273: 5-minute pre-meeting alert for Executive Meetings

- New `executive_meeting_alert_state` table (per-user, per-meeting) tracking
  `dismissed`/`acknowledged`. Migration via `pnpm --filter @workspace/db
  run push-force`.
- New API routes in artifacts/api-server/src/routes/executive-meetings.ts:
  GET /alert-state, POST /:id/alert-state, POST /:id/postpone-minutes,
  POST /:id/reschedule, POST /:id/cancel. All three mutation routes
  acquire a row lock 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. Postpone-minutes rejects ranges that would cross midnight
  (use reschedule for cross-day moves).
- Alert-state route uses race-safe onConflictDoNothing upsert + a
  conditional UPDATE ... RETURNING so transition audits never duplicate.
- New i18n keys `executiveMeetings.alert.*` in en.json + ar.json,
  including the cancel-confirm prompt.
- New component artifacts/tx-os/src/components/executive-meetings/
  upcoming-meeting-alert.tsx — globally mounted in App.tsx inside
  AuthProvider. Draggable with localStorage position persistence,
  RTL-aware, polls every 30s, shows start–end window, postpone-by-
  minutes chips [5,10,15,30,45,60], full reschedule sub-form, and a
  Cancel-meeting flow that requires an explicit confirm step before
  the destructive call fires (gated on confirmCancel state).
- Playwright spec executive-meetings-upcoming-alert.spec.mjs with
  6 scenarios: appear+Done, postpone-10 shifts times, cancel-with-
  confirm, dismiss audit, postpone-chip+conflict-warning toast,
  AR/RTL render. All 6 pass.
- replit.md updated with the new table, routes, and migration step.

Pre-existing tsc errors at lines 489, 605, and 2039 of
executive-meetings.ts are not touched by this task.
This commit is contained in:
riyadhafraa
2026-05-01 11:56:14 +00:00
parent 5bd5eb9dc7
commit a72c00fe19
9 changed files with 1705 additions and 0 deletions
@@ -22,6 +22,7 @@ import {
executiveMeetingAuditLogsTable,
executiveMeetingPdfArchivesTable,
executiveMeetingFontSettingsTable,
executiveMeetingAlertStateTable,
rolesTable,
usersTable,
type ExecutiveMeetingAttendee,
@@ -680,6 +681,471 @@ router.patch(
},
);
// #273: ---- Upcoming-meeting alert (5-minute pre-alarm) endpoints ----
// Parse "HH:MM" or "HH:MM:SS" -> minutes-of-day. Returns null on missing/invalid.
function parseTimeToMinutes(t: string | null | undefined): number | null {
if (!t) return null;
const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(t);
if (!m) return null;
const h = Number(m[1]);
const mm = Number(m[2]);
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
return h * 60 + mm;
}
function formatMinutesAsTime(mins: number): string {
const safe = ((mins % (24 * 60)) + 24 * 60) % (24 * 60);
const h = Math.floor(safe / 60);
const m = safe % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`;
}
// #273: pass a transaction-like executor so the conflict scan reads inside
// the same DB snapshot as the UPDATE that just shifted the row.
async function detectMeetingConflicts(
exec: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0],
date: string,
excludeId: number,
startTime: string,
endTime: string,
): Promise<boolean> {
const newStart = parseTimeToMinutes(startTime);
const newEnd = parseTimeToMinutes(endTime);
if (newStart == null || newEnd == null) return false;
const others = await exec
.select({
id: executiveMeetingsTable.id,
start: executiveMeetingsTable.startTime,
end: executiveMeetingsTable.endTime,
status: executiveMeetingsTable.status,
})
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, date));
for (const o of others) {
if (o.id === excludeId) continue;
if (o.status === "cancelled" || o.status === "completed") continue;
const s = parseTimeToMinutes(o.start);
const e = parseTimeToMinutes(o.end);
if (s == null || e == null) continue;
if (s < newEnd && e > newStart) return true;
}
return false;
}
const alertActionSchema = z.object({
action: z.enum(["shown", "acknowledged", "dismissed"]),
});
const postponeMinutesSchema = z.object({
minutes: z.number().int().min(1).max(24 * 60),
});
const rescheduleSchema = z.object({
meetingDate: dateSchema,
startTime: timeSchema,
endTime: timeSchema,
note: z.string().max(500).optional(),
});
router.get(
"/executive-meetings/alert-state",
requireExecutiveAccess,
async (req, res): Promise<void> => {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const dateRaw = String(req.query.date ?? "");
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
res.status(400).json({ error: "Invalid date", code: "invalid_date" });
return;
}
const userId = req.session.userId;
const rows = await db
.select({
meetingId: executiveMeetingAlertStateTable.meetingId,
dismissed: executiveMeetingAlertStateTable.dismissed,
acknowledged: executiveMeetingAlertStateTable.acknowledged,
})
.from(executiveMeetingAlertStateTable)
.innerJoin(
executiveMeetingsTable,
eq(executiveMeetingsTable.id, executiveMeetingAlertStateTable.meetingId),
)
.where(
and(
eq(executiveMeetingAlertStateTable.userId, userId),
eq(executiveMeetingsTable.meetingDate, dateRaw),
),
);
res.json({ states: rows });
},
);
router.post(
"/executive-meetings/:id/alert-state",
requireExecutiveAccess,
async (req, res): Promise<void> => {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const body = parseBody(res, alertActionSchema, req.body);
if (!body) return;
const meeting = await fetchMeetingWithAttendees(id);
if (!meeting) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const userId = req.session.userId;
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
// one INSERT actually creates the row, so we audit "shown" once.
const inserted = await tx
.insert(executiveMeetingAlertStateTable)
.values({
meetingId: id,
userId,
dismissed: body.action === "dismissed",
acknowledged: body.action === "acknowledged",
})
.onConflictDoNothing({
target: [
executiveMeetingAlertStateTable.meetingId,
executiveMeetingAlertStateTable.userId,
],
})
.returning({ id: executiveMeetingAlertStateTable.id });
if (inserted.length > 0) {
await logAudit(tx, {
action: "meeting_alert_shown",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
if (body.action === "acknowledged") {
await logAudit(tx, {
action: "meeting_alert_acknowledged",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
} else if (body.action === "dismissed") {
await logAudit(tx, {
action: "meeting_alert_dismissed",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
}
return;
}
// Row exists. Use a conditional UPDATE so concurrent clicks across
// tabs only fire one audit row per real transition (false -> true).
if (body.action === "acknowledged") {
const upd = await tx
.update(executiveMeetingAlertStateTable)
.set({ acknowledged: true })
.where(
and(
eq(executiveMeetingAlertStateTable.meetingId, id),
eq(executiveMeetingAlertStateTable.userId, userId),
eq(executiveMeetingAlertStateTable.acknowledged, false),
),
)
.returning({ id: executiveMeetingAlertStateTable.id });
if (upd.length > 0) {
await logAudit(tx, {
action: "meeting_alert_acknowledged",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
}
} else if (body.action === "dismissed") {
const upd = await tx
.update(executiveMeetingAlertStateTable)
.set({ dismissed: true })
.where(
and(
eq(executiveMeetingAlertStateTable.meetingId, id),
eq(executiveMeetingAlertStateTable.userId, userId),
eq(executiveMeetingAlertStateTable.dismissed, false),
),
)
.returning({ id: executiveMeetingAlertStateTable.id });
if (upd.length > 0) {
await logAudit(tx, {
action: "meeting_alert_dismissed",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
}
}
});
res.json({ ok: true });
},
);
router.post(
"/executive-meetings/:id/postpone-minutes",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const body = parseBody(res, postponeMinutesSchema, req.body);
if (!body) return;
const userId = req.session.userId!;
// #273: lock-then-mutate inside a single transaction so concurrent
// postpone/reschedule/cancel requests serialize and audit logs reflect
// the actual committed transition.
type TxResult =
| {
ok: true;
meetingDate: string;
newStart: string;
newEnd: string;
conflicts: boolean;
}
| { ok: false; status: number; error: string; code: string };
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
const [locked] = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id))
.for("update");
if (!locked) {
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
}
const startMin = parseTimeToMinutes(locked.startTime);
const endMin = parseTimeToMinutes(locked.endTime);
if (startMin == null || endMin == null) {
return {
ok: false,
status: 400,
error: "Meeting has no scheduled time to postpone",
code: "no_time_window",
};
}
if (
startMin + body.minutes >= 24 * 60 ||
endMin + body.minutes > 24 * 60
) {
return {
ok: false,
status: 400,
error: "Postponing by that many minutes would cross midnight",
code: "crosses_midnight",
};
}
const newStart = formatMinutesAsTime(startMin + body.minutes);
const newEnd = formatMinutesAsTime(endMin + body.minutes);
await tx
.update(executiveMeetingsTable)
.set({ startTime: newStart, endTime: newEnd, status: "postponed" })
.where(eq(executiveMeetingsTable.id, id));
await logAudit(tx, {
action: "meeting_postponed_minutes",
entityType: "meeting",
entityId: id,
oldValue: {
startTime: locked.startTime,
endTime: locked.endTime,
status: locked.status,
},
newValue: {
startTime: newStart,
endTime: newEnd,
status: "postponed",
minutes: body.minutes,
},
performedBy: userId,
});
const conflicts = await detectMeetingConflicts(
tx,
locked.meetingDate,
id,
newStart,
newEnd,
);
return {
ok: true,
meetingDate: locked.meetingDate,
newStart,
newEnd,
conflicts,
};
});
if (!txResult.ok) {
res.status(txResult.status).json({ error: txResult.error, code: txResult.code });
return;
}
void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]);
const updated = await fetchMeetingWithAttendees(id);
res.json({ ok: true, conflicts: txResult.conflicts, meeting: updated });
},
);
router.post(
"/executive-meetings/:id/reschedule",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const body = parseBody(res, rescheduleSchema, req.body);
if (!body) return;
const startMin = parseTimeToMinutes(body.startTime);
const endMin = parseTimeToMinutes(body.endTime);
if (startMin == null || endMin == null || startMin >= endMin) {
res.status(400).json({
error: "End time must be after start time",
code: "invalid_time_range",
});
return;
}
const userId = req.session.userId!;
// #273: lock the row inside the tx so a concurrent edit cannot race us.
type TxResult =
| { ok: true; oldDate: string; conflicts: boolean }
| { ok: false; status: number; error: string; code: string };
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
const [locked] = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id))
.for("update");
if (!locked) {
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
}
// If the daily number would clash on the new date, allocate a fresh one.
let dailyNumber = locked.dailyNumber;
if (body.meetingDate !== locked.meetingDate) {
dailyNumber = await nextDailyNumber(tx, body.meetingDate);
}
await tx
.update(executiveMeetingsTable)
.set({
meetingDate: body.meetingDate,
startTime: body.startTime,
endTime: body.endTime,
status: "postponed",
dailyNumber,
})
.where(eq(executiveMeetingsTable.id, id));
await logAudit(tx, {
action: "meeting_rescheduled",
entityType: "meeting",
entityId: id,
oldValue: {
meetingDate: locked.meetingDate,
startTime: locked.startTime,
endTime: locked.endTime,
status: locked.status,
dailyNumber: locked.dailyNumber,
},
newValue: {
meetingDate: body.meetingDate,
startTime: body.startTime,
endTime: body.endTime,
status: "postponed",
dailyNumber,
note: body.note ?? null,
},
performedBy: userId,
});
const conflicts = await detectMeetingConflicts(
tx,
body.meetingDate,
id,
body.startTime,
body.endTime,
);
return { ok: true, oldDate: locked.meetingDate, conflicts };
});
if (!txResult.ok) {
res.status(txResult.status).json({ error: txResult.error, code: txResult.code });
return;
}
void emitExecutiveMeetingsDaysChanged([
txResult.oldDate,
body.meetingDate,
]);
const updated = await fetchMeetingWithAttendees(id);
res.json({ ok: true, conflicts: txResult.conflicts, meeting: updated });
},
);
router.post(
"/executive-meetings/:id/cancel",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const userId = req.session.userId!;
// #273: lock + idempotent UPDATE — only audit on the real status change.
type TxResult =
| { ok: true; meetingDate: string; changed: boolean }
| { ok: false; status: number; error: string; code: string };
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
const [locked] = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id))
.for("update");
if (!locked) {
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
}
if (locked.status === "cancelled") {
return { ok: true, meetingDate: locked.meetingDate, changed: false };
}
await tx
.update(executiveMeetingsTable)
.set({ status: "cancelled" })
.where(eq(executiveMeetingsTable.id, id));
await logAudit(tx, {
action: "meeting_cancelled",
entityType: "meeting",
entityId: id,
oldValue: { status: locked.status },
newValue: { status: "cancelled" },
performedBy: userId,
});
return { ok: true, meetingDate: locked.meetingDate, changed: true };
});
if (!txResult.ok) {
res.status(txResult.status).json({ error: txResult.error, code: txResult.code });
return;
}
if (txResult.changed) {
void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]);
}
const updated = await fetchMeetingWithAttendees(id);
res.json({ ok: true, meeting: updated });
},
);
router.delete(
"/executive-meetings/:id",
requireExecutiveAccess,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+2
View File
@@ -4,6 +4,7 @@ import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AuthProvider } from "@/contexts/AuthContext";
import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
import NotFound from "@/pages/not-found";
import LoginPage from "@/pages/login";
import RegisterPage from "@/pages/register";
@@ -37,6 +38,7 @@ function Router() {
return (
<AuthProvider>
<NotificationsSocketBridge />
<UpcomingMeetingAlert />
<Switch>
<Route path="/login" component={LoginPage} />
<Route path="/register" component={RegisterPage} />
@@ -0,0 +1,813 @@
// #273: Floating, draggable 5-minute pre-meeting alert.
// Mounted globally from App.tsx so it stays visible while the user
// navigates around tx-os. Polls /api/executive-meetings for today and
// the per-user alert state every 30 s, and listens to React Query
// invalidations from the executive_meetings_changed socket event.
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocation } from "wouter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import { Calendar, Clock, GripVertical, X } from "lucide-react";
const POSITION_KEY = "txos.upcomingMeetingAlert.position";
const ALERT_LEAD_MINUTES = 5;
const POLL_MS = 30_000;
type Meeting = {
id: number;
dailyNumber: number;
titleAr: string;
titleEn: string;
meetingDate: string;
startTime: string | null;
endTime: string | null;
location: string | null;
meetingUrl: string | null;
status: string;
};
type DayResponse = { date: string; meetings: Meeting[] };
type AlertState = {
meetingId: number;
dismissed: boolean;
acknowledged: boolean;
};
type Capabilities = {
canRead: boolean;
canMutate: boolean;
};
function parseTimeToMinutes(t: string | null | undefined): number | null {
if (!t) return null;
const m = /^(\d{1,2}):(\d{2})/.exec(t);
if (!m) return null;
const h = Number(m[1]);
const mm = Number(m[2]);
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
return h * 60 + mm;
}
function todayLocalDate(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function nowLocalMinutes(): number {
const d = new Date();
return d.getHours() * 60 + d.getMinutes();
}
function formatTime(t: string | null): string {
if (!t) return "";
const m = /^(\d{1,2}):(\d{2})/.exec(t);
if (!m) return t;
return `${m[1].padStart(2, "0")}:${m[2]}`;
}
async function apiJson<T>(input: string, init?: RequestInit): Promise<T> {
const res = await fetch(input, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const data = text ? (JSON.parse(text) as unknown) : ({} as unknown);
if (!res.ok) {
throw new Error(
(data as { error?: string }).error || `HTTP ${res.status}`,
);
}
return data as T;
}
type DragPosition = { x: number; y: number };
function loadPosition(): DragPosition | null {
try {
const raw = localStorage.getItem(POSITION_KEY);
if (!raw) return null;
const obj = JSON.parse(raw) as DragPosition;
if (typeof obj.x !== "number" || typeof obj.y !== "number") return null;
return obj;
} catch {
return null;
}
}
function savePosition(pos: DragPosition) {
try {
localStorage.setItem(POSITION_KEY, JSON.stringify(pos));
} catch {
/* ignore storage errors */
}
}
function defaultPosition(): DragPosition {
// Top-right corner with 16px margin; gracefully degrades on small screens.
if (typeof window === "undefined") return { x: 16, y: 16 };
const w = Math.min(380, window.innerWidth - 32);
return { x: Math.max(16, window.innerWidth - w - 16), y: 80 };
}
export function UpcomingMeetingAlert() {
const { user, isLoading: authLoading } = useAuth();
const { t, i18n } = useTranslation();
const isRtl = i18n.language === "ar";
const { toast } = useToast();
const queryClient = useQueryClient();
const [, setLocation] = useLocation();
const [now, setNow] = useState<number>(() => nowLocalMinutes());
const [postponeOpen, setPostponeOpen] = useState(false);
const [savingAction, setSavingAction] = useState<string | null>(null);
// Tick the clock every minute so the countdown stays current. Also
// tick when the tab regains focus so users coming back to it don't
// see a stale 5-minutes-ago timestamp.
useEffect(() => {
const interval = setInterval(() => setNow(nowLocalMinutes()), 30_000);
const onFocus = () => setNow(nowLocalMinutes());
window.addEventListener("focus", onFocus);
return () => {
clearInterval(interval);
window.removeEventListener("focus", onFocus);
};
}, []);
const today = todayLocalDate();
const { data: caps } = useQuery<Capabilities>({
queryKey: ["/api/executive-meetings/me", user?.id ?? null],
enabled: !authLoading && !!user,
queryFn: async () => {
const res = await fetch("/api/executive-meetings/me", {
credentials: "include",
});
if (res.status === 401 || res.status === 403) {
return { canRead: false, canMutate: false };
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as Capabilities;
return { canRead: json.canRead, canMutate: json.canMutate };
},
staleTime: 5 * 60_000,
});
const enabled = !!user && !!caps?.canRead;
const { data: dayData } = useQuery<DayResponse>({
queryKey: ["/api/executive-meetings", today],
enabled,
queryFn: async () => {
const res = await fetch(`/api/executive-meetings?date=${today}`, {
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
refetchInterval: POLL_MS,
refetchOnWindowFocus: true,
});
const { data: alertStateData, refetch: refetchAlertState } = useQuery<{
states: AlertState[];
}>({
queryKey: ["/api/executive-meetings/alert-state", today, user?.id ?? null],
enabled,
queryFn: async () => {
const res = await fetch(
`/api/executive-meetings/alert-state?date=${today}`,
{ credentials: "include" },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
refetchInterval: POLL_MS,
});
const stateById = useMemo(() => {
const map = new Map<number, AlertState>();
for (const s of alertStateData?.states ?? []) map.set(s.meetingId, s);
return map;
}, [alertStateData]);
// Pick the soonest meeting that:
// - is on today,
// - has a known startTime,
// - is not cancelled or completed,
// - hasn't already started + ended (i.e. start - now is in [-5, 5]),
// - hasn't been acknowledged or dismissed by this user.
// We allow up to 5 minutes "after" the scheduled start so the alert
// doesn't vanish the second the meeting begins.
const eligible = useMemo(() => {
const meetings = dayData?.meetings ?? [];
const candidates = meetings
.map((m) => {
const s = parseTimeToMinutes(m.startTime);
if (s == null) return null;
if (m.status === "cancelled" || m.status === "completed") return null;
const state = stateById.get(m.id);
if (state?.dismissed || state?.acknowledged) return null;
const delta = s - now; // positive = in the future
if (delta > ALERT_LEAD_MINUTES) return null;
if (delta < -ALERT_LEAD_MINUTES) return null;
return { meeting: m, delta };
})
.filter(
(
x,
): x is {
meeting: Meeting;
delta: number;
} => !!x,
);
candidates.sort((a, b) => a.delta - b.delta);
return candidates[0] ?? null;
}, [dayData, stateById, now]);
// Record "shown" the first time we surface a given meeting so admins
// can audit who actually saw the alert. We use a ref to dedupe.
const shownRef = useRef<Set<number>>(new Set());
useEffect(() => {
if (!eligible || !enabled) return;
const id = eligible.meeting.id;
const state = stateById.get(id);
if (state) return; // already has a row -> already counted as shown
if (shownRef.current.has(id)) return;
shownRef.current.add(id);
void apiJson(`/api/executive-meetings/${id}/alert-state`, {
method: "POST",
body: JSON.stringify({ action: "shown" }),
})
.then(() => refetchAlertState())
.catch(() => {
shownRef.current.delete(id);
});
}, [eligible, enabled, stateById, refetchAlertState]);
// ----- Drag handling -----
const [pos, setPos] = useState<DragPosition>(
() => loadPosition() ?? defaultPosition(),
);
const dragState = useRef<{
pointerId: number;
offsetX: number;
offsetY: number;
} | null>(null);
const onDragStart = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0 && e.pointerType === "mouse") return;
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const rect = target.parentElement?.getBoundingClientRect();
dragState.current = {
pointerId: e.pointerId,
offsetX: e.clientX - (rect?.left ?? 0),
offsetY: e.clientY - (rect?.top ?? 0),
};
};
const onDragMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const st = dragState.current;
if (!st || st.pointerId !== e.pointerId) return;
const w = window.innerWidth;
const h = window.innerHeight;
const target = e.currentTarget.parentElement;
const elW = target?.offsetWidth ?? 360;
const elH = target?.offsetHeight ?? 160;
const x = Math.max(8, Math.min(w - elW - 8, e.clientX - st.offsetX));
const y = Math.max(8, Math.min(h - elH - 8, e.clientY - st.offsetY));
setPos({ x, y });
};
const onDragEnd = (e: ReactPointerEvent<HTMLDivElement>) => {
const st = dragState.current;
if (!st || st.pointerId !== e.pointerId) return;
e.currentTarget.releasePointerCapture(e.pointerId);
dragState.current = null;
setPos((p) => {
savePosition(p);
return p;
});
};
const handleDone = useCallback(async () => {
if (!eligible) return;
const id = eligible.meeting.id;
setSavingAction("done");
try {
await apiJson(`/api/executive-meetings/${id}/alert-state`, {
method: "POST",
body: JSON.stringify({ action: "acknowledged" }),
});
await refetchAlertState();
toast({ title: t("executiveMeetings.alert.doneToast") });
} catch (err) {
toast({
title: t("executiveMeetings.alert.saveFailed"),
description: err instanceof Error ? err.message : String(err),
variant: "destructive",
});
} finally {
setSavingAction(null);
}
}, [eligible, refetchAlertState, t, toast]);
const handleDismiss = useCallback(async () => {
if (!eligible) return;
const id = eligible.meeting.id;
setSavingAction("dismiss");
try {
await apiJson(`/api/executive-meetings/${id}/alert-state`, {
method: "POST",
body: JSON.stringify({ action: "dismissed" }),
});
await refetchAlertState();
toast({ title: t("executiveMeetings.alert.dismissToast") });
} catch (err) {
toast({
title: t("executiveMeetings.alert.saveFailed"),
description: err instanceof Error ? err.message : String(err),
variant: "destructive",
});
} finally {
setSavingAction(null);
}
}, [eligible, refetchAlertState, t, toast]);
if (!enabled || !eligible) return null;
const meeting = eligible.meeting;
const title = isRtl
? meeting.titleAr || meeting.titleEn
: meeting.titleEn || meeting.titleAr;
const minutesAway = Math.max(0, eligible.delta);
const headline =
minutesAway === 0
? t("executiveMeetings.alert.startsNow")
: t("executiveMeetings.alert.minutesAway", { n: minutesAway });
return (
<>
<div
role="dialog"
aria-live="polite"
aria-label={t("executiveMeetings.alert.title")}
data-testid="upcoming-meeting-alert"
dir={isRtl ? "rtl" : "ltr"}
className="fixed z-[60] w-[min(380px,calc(100vw-32px))] rounded-lg border border-amber-300 bg-amber-50 text-amber-950 shadow-xl dark:border-amber-700 dark:bg-amber-950 dark:text-amber-50"
style={{ left: pos.x, top: pos.y }}
>
<div className="flex items-center justify-between gap-2 border-b border-amber-200 px-3 py-2 dark:border-amber-800">
<div
className="flex flex-1 cursor-grab items-center gap-2 select-none active:cursor-grabbing"
onPointerDown={onDragStart}
onPointerMove={onDragMove}
onPointerUp={onDragEnd}
onPointerCancel={onDragEnd}
data-testid="alert-drag-handle"
aria-label={t("executiveMeetings.alert.dragHandle")}
>
<GripVertical className="h-4 w-4 opacity-60" aria-hidden="true" />
<span className="text-sm font-semibold">
{t("executiveMeetings.alert.title")}
</span>
</div>
<button
type="button"
onClick={handleDismiss}
disabled={savingAction !== null}
aria-label={t("executiveMeetings.alert.dismiss")}
data-testid="alert-close"
className="rounded p-1 hover:bg-amber-200/60 dark:hover:bg-amber-800/60"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="px-3 py-3">
<div
className="text-sm font-medium"
data-testid="alert-meeting-title"
// titles may contain sanitized inline rich-text from the
// schedule editor; show as plain text only here.
>
{title.replace(/<[^>]+>/g, "").trim() || title}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span
className="font-semibold text-amber-700 dark:text-amber-300"
data-testid="alert-countdown"
>
{headline}
</span>
{meeting.startTime ? (
<span
className="inline-flex items-center gap-1 opacity-80"
data-testid="alert-time-window"
>
<Clock className="h-3 w-3" aria-hidden="true" />
{meeting.endTime
? `${formatTime(meeting.startTime)} ${formatTime(meeting.endTime)}`
: t("executiveMeetings.alert.atTime", {
time: formatTime(meeting.startTime),
})}
</span>
) : null}
{meeting.location ? (
<span className="inline-flex items-center gap-1 opacity-80">
<Calendar className="h-3 w-3" aria-hidden="true" />
{meeting.location}
</span>
) : null}
</div>
</div>
<div className="flex items-center gap-2 border-t border-amber-200 px-3 py-2 dark:border-amber-800">
<Button
type="button"
size="sm"
onClick={handleDone}
disabled={savingAction !== null}
data-testid="alert-done"
>
{t("executiveMeetings.alert.done")}
</Button>
{caps?.canMutate ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setPostponeOpen(true)}
disabled={savingAction !== null}
data-testid="alert-postpone"
>
{t("executiveMeetings.alert.postpone")}
</Button>
) : null}
<Button
type="button"
size="sm"
variant="ghost"
className="ms-auto"
onClick={() => {
setLocation("/executive-meetings");
}}
data-testid="alert-open"
>
{t("executiveMeetings.alert.openMeetings")}
</Button>
</div>
</div>
{caps?.canMutate ? (
<PostponeDialog
open={postponeOpen}
onOpenChange={setPostponeOpen}
meeting={meeting}
isRtl={isRtl}
onSaved={async () => {
setPostponeOpen(false);
// Invalidate today and the alert-state cache so the alert
// disappears (or re-appears) immediately based on the new
// status / time window.
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["/api/executive-meetings"],
}),
refetchAlertState(),
]);
}}
/>
) : null}
</>
);
}
// ---------- Postpone sub-modal ----------
type PostponeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
meeting: Meeting;
isRtl: boolean;
onSaved: () => void | Promise<void>;
};
function PostponeDialog({
open,
onOpenChange,
meeting,
isRtl,
onSaved,
}: PostponeDialogProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [minutes, setMinutes] = useState<string>("10");
const [resDate, setResDate] = useState<string>(meeting.meetingDate);
const [resStart, setResStart] = useState<string>(
meeting.startTime ? meeting.startTime.slice(0, 5) : "",
);
const [resEnd, setResEnd] = useState<string>(
meeting.endTime ? meeting.endTime.slice(0, 5) : "",
);
const [resNote, setResNote] = useState<string>("");
const [busy, setBusy] = useState<string | null>(null);
const [confirmCancel, setConfirmCancel] = useState<boolean>(false);
// Reset local form state whenever the dialog opens against a new meeting
// so stale values don't leak between alerts.
useEffect(() => {
if (!open) return;
setMinutes("10");
setResDate(meeting.meetingDate);
setResStart(meeting.startTime ? meeting.startTime.slice(0, 5) : "");
setResEnd(meeting.endTime ? meeting.endTime.slice(0, 5) : "");
setResNote("");
setBusy(null);
setConfirmCancel(false);
}, [open, meeting.id, meeting.meetingDate, meeting.startTime, meeting.endTime]);
const title = isRtl
? meeting.titleAr || meeting.titleEn
: meeting.titleEn || meeting.titleAr;
const titleText = title.replace(/<[^>]+>/g, "").trim() || title;
const handleResult = (
res: { conflicts?: boolean } | undefined,
successKey: string,
) => {
toast({ title: t(successKey) });
if (res?.conflicts) {
toast({
title: t("executiveMeetings.alert.conflictWarning"),
variant: "destructive",
});
}
};
const handleErr = (err: unknown) => {
toast({
title: t("executiveMeetings.alert.saveFailed"),
description: err instanceof Error ? err.message : String(err),
variant: "destructive",
});
};
const onPostponeMinutes = async () => {
const n = Number(minutes);
if (!Number.isFinite(n) || n <= 0) return;
setBusy("minutes");
try {
const res = await apiJson<{ conflicts?: boolean }>(
`/api/executive-meetings/${meeting.id}/postpone-minutes`,
{ method: "POST", body: JSON.stringify({ minutes: Math.floor(n) }) },
);
handleResult(res, "executiveMeetings.alert.saved");
await onSaved();
} catch (err) {
handleErr(err);
} finally {
setBusy(null);
}
};
const onReschedule = async () => {
if (!resDate || !resStart || !resEnd) return;
setBusy("reschedule");
try {
const res = await apiJson<{ conflicts?: boolean }>(
`/api/executive-meetings/${meeting.id}/reschedule`,
{
method: "POST",
body: JSON.stringify({
meetingDate: resDate,
startTime: `${resStart}:00`,
endTime: `${resEnd}:00`,
note: resNote || undefined,
}),
},
);
handleResult(res, "executiveMeetings.alert.saved");
await onSaved();
} catch (err) {
handleErr(err);
} finally {
setBusy(null);
}
};
const onCancelMeeting = async () => {
setBusy("cancel");
try {
await apiJson(`/api/executive-meetings/${meeting.id}/cancel`, {
method: "POST",
body: JSON.stringify({}),
});
toast({ title: t("executiveMeetings.alert.saved") });
await onSaved();
} catch (err) {
handleErr(err);
} finally {
setBusy(null);
}
};
const noTimeWindow = !meeting.startTime || !meeting.endTime;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
dir={isRtl ? "rtl" : "ltr"}
data-testid="postpone-dialog"
className="sm:max-w-md"
>
<DialogHeader>
<DialogTitle>{t("executiveMeetings.alert.postponeTitle")}</DialogTitle>
<DialogDescription>
{t("executiveMeetings.alert.postponeIntro", { title: titleText })}
</DialogDescription>
</DialogHeader>
<section className="space-y-2 rounded border p-3">
<Label className="text-sm font-medium">
{t("executiveMeetings.alert.postponeMinutesLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{noTimeWindow
? t("executiveMeetings.alert.noTimeWindow")
: t("executiveMeetings.alert.postponeMinutesHint")}
</p>
<div className="flex flex-wrap items-center gap-1">
{[5, 10, 15, 30, 45, 60].map((n) => (
<Button
key={n}
type="button"
size="sm"
variant={Number(minutes) === n ? "default" : "outline"}
disabled={noTimeWindow || busy !== null}
onClick={() => setMinutes(String(n))}
data-testid={`postpone-chip-${n}`}
className="h-7 px-2 text-xs"
>
{n}
</Button>
))}
</div>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={1440}
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
placeholder={t(
"executiveMeetings.alert.postponeMinutesPlaceholder",
)}
disabled={noTimeWindow || busy !== null}
data-testid="postpone-minutes-input"
className="w-28"
/>
<Button
type="button"
size="sm"
onClick={onPostponeMinutes}
disabled={
noTimeWindow ||
busy !== null ||
!Number.isFinite(Number(minutes)) ||
Number(minutes) <= 0
}
data-testid="postpone-minutes-apply"
>
{t("executiveMeetings.alert.postponeMinutesApply", {
n: Number(minutes) || 0,
})}
</Button>
</div>
</section>
<section className="space-y-2 rounded border p-3">
<Label className="text-sm font-medium">
{t("executiveMeetings.alert.rescheduleLabel")}
</Label>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">
{t("executiveMeetings.alert.rescheduleDate")}
</Label>
<Input
type="date"
value={resDate}
onChange={(e) => setResDate(e.target.value)}
disabled={busy !== null}
data-testid="reschedule-date"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("executiveMeetings.alert.rescheduleStart")}
</Label>
<Input
type="time"
value={resStart}
onChange={(e) => setResStart(e.target.value)}
disabled={busy !== null}
data-testid="reschedule-start"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("executiveMeetings.alert.rescheduleEnd")}
</Label>
<Input
type="time"
value={resEnd}
onChange={(e) => setResEnd(e.target.value)}
disabled={busy !== null}
data-testid="reschedule-end"
/>
</div>
</div>
<Textarea
value={resNote}
onChange={(e) => setResNote(e.target.value)}
placeholder={t("executiveMeetings.alert.rescheduleNote")}
disabled={busy !== null}
rows={2}
data-testid="reschedule-note"
/>
<Button
type="button"
size="sm"
onClick={onReschedule}
disabled={busy !== null || !resDate || !resStart || !resEnd}
data-testid="reschedule-apply"
>
{t("executiveMeetings.alert.rescheduleApply")}
</Button>
</section>
<section className="space-y-2 rounded border p-3">
<Label className="text-sm font-medium">
{t("executiveMeetings.alert.cancelLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("executiveMeetings.alert.cancelHint")}
</p>
{!confirmCancel ? (
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => setConfirmCancel(true)}
disabled={busy !== null}
data-testid="cancel-meeting"
>
{t("executiveMeetings.alert.cancelApply")}
</Button>
) : (
<div
className="flex flex-col gap-2"
data-testid="cancel-confirm-block"
>
<p className="text-xs font-medium text-destructive">
{t("executiveMeetings.alert.cancelConfirmPrompt")}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="destructive"
onClick={onCancelMeeting}
disabled={busy !== null}
data-testid="cancel-meeting-confirm"
>
{t("executiveMeetings.alert.cancelConfirmYes")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirmCancel(false)}
disabled={busy !== null}
data-testid="cancel-meeting-back"
>
{t("executiveMeetings.alert.back")}
</Button>
</div>
</div>
)}
</section>
</DialogContent>
</Dialog>
);
}
+35
View File
@@ -1024,6 +1024,41 @@
},
"dragRow": "اسحب لإعادة الترتيب",
"editMeetingTitle": "تعديل عنوان الاجتماع",
"alert": {
"title": "اجتماع يبدأ قريبًا",
"minutesAway": "يبدأ خلال {{n}} دقيقة",
"startsNow": "يبدأ الآن",
"atTime": "في {{time}}",
"done": "تم",
"postpone": "تأجيل",
"dismiss": "حذف التنبيه",
"dragHandle": "اسحب للتحريك",
"doneToast": "تم تأكيد المشاهدة",
"dismissToast": "تم حذف التنبيه",
"openMeetings": "فتح الاجتماعات",
"postponeTitle": "تأجيل الاجتماع",
"postponeIntro": "اختر طريقة تأجيل «{{title}}».",
"postponeMinutesLabel": "تأجيل بعدد دقائق",
"postponeMinutesHint": "سيتم إزاحة وقتي البداية والنهاية بنفس المقدار.",
"postponeMinutesPlaceholder": "مثال: 10",
"postponeMinutesApply": "تأجيل {{n}} دقيقة",
"rescheduleLabel": "إعادة جدولة لتاريخ/وقت جديد",
"rescheduleDate": "التاريخ",
"rescheduleStart": "البداية",
"rescheduleEnd": "النهاية",
"rescheduleNote": "ملاحظة (اختياري)",
"rescheduleApply": "إعادة الجدولة",
"cancelLabel": "إلغاء الاجتماع",
"cancelHint": "يحدد الاجتماع كمُلغى مع الاحتفاظ بسجل الحضور.",
"cancelApply": "إلغاء الاجتماع",
"cancelConfirmPrompt": "هل أنت متأكد؟ سيتم وضع الاجتماع كمُلغى.",
"cancelConfirmYes": "نعم، إلغاء",
"back": "رجوع",
"saved": "تم الحفظ",
"saveFailed": "تعذّر حفظ التغييرات",
"conflictWarning": "تنبيه: الوقت الجديد يتداخل مع اجتماع آخر في نفس اليوم.",
"noTimeWindow": "هذا الاجتماع بدون وقت محدد، لا يمكن تأجيله بدقائق."
},
"schedule": {
"addRow": "أضف اجتماع",
"newMeetingDefaultAr": "اجتماع جديد",
+35
View File
@@ -941,6 +941,41 @@
"all": "Merge entire row",
"unmerge": "Unmerge"
},
"alert": {
"title": "Meeting starting soon",
"minutesAway": "Starts in {{n}} min",
"startsNow": "Starting now",
"atTime": "at {{time}}",
"done": "Done",
"postpone": "Postpone",
"dismiss": "Delete alert",
"dragHandle": "Drag to move",
"doneToast": "Marked as acknowledged",
"dismissToast": "Alert dismissed",
"openMeetings": "Open meetings",
"postponeTitle": "Postpone meeting",
"postponeIntro": "Choose how to postpone “{{title}}”.",
"postponeMinutesLabel": "Postpone by minutes",
"postponeMinutesHint": "Both start and end times shift by the same amount.",
"postponeMinutesPlaceholder": "e.g. 10",
"postponeMinutesApply": "Postpone by {{n}} min",
"rescheduleLabel": "Reschedule to a new date / time",
"rescheduleDate": "Date",
"rescheduleStart": "Start",
"rescheduleEnd": "End",
"rescheduleNote": "Note (optional)",
"rescheduleApply": "Reschedule",
"cancelLabel": "Cancel meeting",
"cancelHint": "Marks the meeting as cancelled. Attendees keep history.",
"cancelApply": "Cancel meeting",
"cancelConfirmPrompt": "Are you sure? The meeting will be marked as cancelled.",
"cancelConfirmYes": "Yes, cancel",
"back": "Back",
"saved": "Saved",
"saveFailed": "Could not save changes",
"conflictWarning": "Heads up: the new time overlaps another meeting on the same day.",
"noTimeWindow": "This meeting has no scheduled time, so it cant be postponed by minutes."
},
"schedule": {
"addRow": "Add meeting",
"newMeetingDefaultAr": "اجتماع جديد",
@@ -0,0 +1,309 @@
// #273: e2e for the floating 5-minute pre-meeting alert. Seeds a
// meeting on today's date with a start time ~3 minutes in the
// future so the global alert appears, then exercises Done /
// Dismiss / Postpone-by-minutes / Cancel and asserts the audit
// log records each action.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the upcoming-meeting-alert test",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
const TEST_TAG = "ALERT_UPCOMING_TEST";
async function loginViaUi(page, username, password) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
async function setLang(page, lang) {
await page.addInitScript((l) => {
try {
window.localStorage.setItem("tx-lang", l);
} catch {
/* ignore */
}
}, lang);
}
function todayLocal() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function timePlusMinutes(deltaMins) {
const d = new Date(Date.now() + deltaMins * 60_000);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${hh}:${mm}:00`;
}
async function nextDailyNumberForToday(date) {
const { rows } = await pool.query(
`SELECT COALESCE(MAX(daily_number), 0) + 1 AS n
FROM executive_meetings
WHERE meeting_date = $1`,
[date],
);
return rows[0].n;
}
async function insertImminentMeeting({ titleAr, titleEn, deltaMins = 3 }) {
const date = todayLocal();
const startTime = timePlusMinutes(deltaMins);
const endTime = timePlusMinutes(deltaMins + 30);
const dn = await nextDailyNumberForToday(date);
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
RETURNING id, start_time, end_time`,
[dn, titleAr, titleEn, date, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return {
id,
date,
startTime: rows[0].start_time,
endTime: rows[0].end_time,
};
}
async function audits(meetingId, action) {
const { rows } = await pool.query(
`SELECT count(*)::int AS c FROM executive_meeting_audit_logs
WHERE entity_type = 'meeting' AND entity_id = $1 AND action = $2`,
[meetingId, action],
);
return rows[0].c;
}
async function getMeetingRow(id) {
const { rows } = await pool.query(
`SELECT start_time, end_time, status FROM executive_meetings WHERE id = $1`,
[id],
);
return rows[0];
}
test.afterAll(async () => {
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_alert_state WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [
createdMeetingIds,
]);
}
await pool.end();
});
test("Upcoming-meeting alert: appears for an imminent meeting and Done acknowledges it", async ({
page,
}) => {
await setLang(page, "en");
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} done AR`,
titleEn: `${TEST_TAG} done EN`,
deltaMins: 3,
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/");
const alert = page.getByTestId("upcoming-meeting-alert");
await expect(alert).toBeVisible({ timeout: 15_000 });
// Title may render the AR or EN variant depending on the admin's
// preferred language stored on the server; assert on the shared tag.
await expect(page.getByTestId("alert-meeting-title")).toContainText(
`${TEST_TAG} done`,
);
await page.getByTestId("alert-done").click();
await expect(alert).toBeHidden({ timeout: 5_000 });
// The audit log records both "shown" and "acknowledged" exactly once
// for this meeting/user.
await expect
.poll(() => audits(m.id, "meeting_alert_shown"), { timeout: 5_000 })
.toBeGreaterThanOrEqual(1);
await expect
.poll(() => audits(m.id, "meeting_alert_acknowledged"), { timeout: 5_000 })
.toBeGreaterThanOrEqual(1);
});
test("Upcoming-meeting alert: Postpone by 10 minutes shifts both start and end and clears the alert", async ({
page,
}) => {
await setLang(page, "en");
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} postpone AR`,
titleEn: `${TEST_TAG} postpone EN`,
deltaMins: 4,
});
const before = await getMeetingRow(m.id);
const beforeStart = before.start_time.slice(0, 5);
await loginViaUi(page, "admin", "admin123");
await page.goto("/");
await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
timeout: 15_000,
});
await page.getByTestId("alert-postpone").click();
const dialog = page.getByTestId("postpone-dialog");
await expect(dialog).toBeVisible();
await page.getByTestId("postpone-minutes-input").fill("10");
await page.getByTestId("postpone-minutes-apply").click();
await expect.poll(async () => {
const r = await getMeetingRow(m.id);
return r.status === "postponed" && r.start_time.slice(0, 5) !== beforeStart;
}, { timeout: 10_000 }).toBe(true);
await expect
.poll(() => audits(m.id, "meeting_postponed_minutes"), { timeout: 5_000 })
.toBeGreaterThanOrEqual(1);
});
test("Upcoming-meeting alert: Cancel marks the meeting cancelled and removes the alert", async ({
page,
}) => {
await setLang(page, "en");
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} cancel AR`,
titleEn: `${TEST_TAG} cancel EN`,
deltaMins: 2,
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/");
await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
timeout: 15_000,
});
await page.getByTestId("alert-postpone").click();
await expect(page.getByTestId("postpone-dialog")).toBeVisible();
await page.getByTestId("cancel-meeting").click();
// Confirmation step protects against accidental cancellation.
await page.getByTestId("cancel-meeting-confirm").click();
await expect.poll(async () => {
const r = await getMeetingRow(m.id);
return r?.status === "cancelled";
}, { timeout: 10_000 }).toBe(true);
await expect
.poll(() => audits(m.id, "meeting_cancelled"), { timeout: 5_000 })
.toBeGreaterThanOrEqual(1);
});
test("Upcoming-meeting alert: Dismiss (X) hides the alert and writes a dismissed audit row", async ({
page,
}) => {
await setLang(page, "en");
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} dismiss AR`,
titleEn: `${TEST_TAG} dismiss EN`,
deltaMins: 3,
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/");
const alert = page.getByTestId("upcoming-meeting-alert");
await expect(alert).toBeVisible({ timeout: 15_000 });
await page.getByTestId("alert-close").click();
await expect(alert).toBeHidden({ timeout: 5_000 });
await expect
.poll(() => audits(m.id, "meeting_alert_dismissed"), { timeout: 5_000 })
.toBeGreaterThanOrEqual(1);
});
test("Upcoming-meeting alert: Postpone-by-minutes chip 5 selects the value and conflict warning fires when overlapping another meeting", async ({
page,
}) => {
await setLang(page, "en");
// Imminent meeting at +3 mins, plus a fixed neighbour starting at +13 mins.
// A 10-minute postpone of the imminent meeting (chip "5" => no overlap;
// chip 10 would overlap the neighbour). We use chip "10" to assert the
// conflict warning toast fires.
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} chip AR`,
titleEn: `${TEST_TAG} chip EN`,
deltaMins: 3,
});
// Manually insert a neighbour 13 minutes from now spanning ~30 min.
const neighbour = await insertImminentMeeting({
titleAr: `${TEST_TAG} neighbour AR`,
titleEn: `${TEST_TAG} neighbour EN`,
deltaMins: 13,
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/");
await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
timeout: 15_000,
});
// Startend window is shown.
await expect(page.getByTestId("alert-time-window")).toBeVisible();
await page.getByTestId("alert-postpone").click();
await expect(page.getByTestId("postpone-dialog")).toBeVisible();
// Click the "10" chip — fills the input.
await page.getByTestId("postpone-chip-10").click();
await expect(page.getByTestId("postpone-minutes-input")).toHaveValue("10");
await page.getByTestId("postpone-minutes-apply").click();
// The new times overlap the neighbour, so a conflict warning toast
// appears. Match either the EN or AR translation since the admin user's
// preferredLanguage may override the test's localStorage hint.
await expect(
page.getByText(/overlaps another meeting|يتداخل مع اجتماع/i),
).toBeVisible({ timeout: 5_000 });
// And the meeting was actually postponed.
await expect
.poll(() => audits(m.id, "meeting_postponed_minutes"), { timeout: 5_000 })
.toBeGreaterThanOrEqual(1);
expect(neighbour.id).toBeGreaterThan(0);
});
test("Upcoming-meeting alert: Arabic locale renders the RTL alert with Arabic title", async ({
page,
}) => {
await setLang(page, "ar");
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} عربي`,
titleEn: `${TEST_TAG} ar EN`,
deltaMins: 3,
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/");
const alert = page.getByTestId("upcoming-meeting-alert");
await expect(alert).toBeVisible({ timeout: 15_000 });
await expect(alert).toHaveAttribute("dir", "rtl");
await expect(page.getByTestId("alert-meeting-title")).toContainText(
`${TEST_TAG} عربي`,
);
// Cleanup: dismiss so it doesn't bleed into other tests' polling.
await page.getByTestId("alert-close").click();
await expect(alert).toBeHidden({ timeout: 5_000 });
// Mark used so the afterAll cleanup picks it up via createdMeetingIds.
expect(m.id).toBeGreaterThan(0);
});
+29
View File
@@ -188,6 +188,35 @@ export const executiveMeetingPdfArchivesTable = pgTable(
},
);
// #273: Per-user dismissal/acknowledgment state for the 5-minute
// upcoming-meeting alert. One row per (meetingId, userId). Absence of
// a row means the user has neither acknowledged ("Done") nor dismissed
// ("Delete alert") the alert for that meeting yet, so the alert is
// eligible to fire when the meeting falls inside the 5-minute window.
export const executiveMeetingAlertStateTable = pgTable(
"executive_meeting_alert_state",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
dismissed: boolean("dismissed").notNull().default(false),
acknowledged: boolean("acknowledged").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
perUserUnique: uniqueIndex(
"executive_meeting_alert_state_meeting_user_unique",
).on(t.meetingId, t.userId),
}),
);
export const executiveMeetingFontSettingsTable = pgTable(
"executive_meeting_font_settings",
{
+16
View File
@@ -84,3 +84,19 @@
`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 startend 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).