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