#635: External meetings + auto-numbering

User asked for three things in the executive-meetings module:
1. Remove the "daily number" input from the add/edit dialog — numbering
   is auto-assigned by the server already, so the field was just noise.
2. Add an "اجتماع خارجي" (external meeting) toggle. When ON, the row
   is force-tinted red in the schedule grid.
3. External meetings must be excluded from the auto-postpone flow:
   the alert "Postpone" button is disabled, and the cascade shift
   skips them as followers.

Changes
- lib/db/src/schema/executive-meetings.ts
  + `is_external boolean not null default false` column. Pushed via
    drizzle-kit (no destructive migration; defaults backfill).
- artifacts/api-server/src/routes/executive-meetings.ts
  + `isExternal` added to base zod fields, POST insert, PATCH update.
  + POST/PATCH force `rowColor='red'` when `isExternal=true`.
  + POST /:id/postpone-minutes rejects external meetings with
    HTTP 409 + code `external_meeting_no_auto_postpone`.
  + `computeCascadeShift` filters out external followers (preview +
    writer stay in lockstep, as the comment promises).
- artifacts/tx-os/src/pages/executive-meetings.tsx
  + Meeting type + MeetingFormState gain `isExternal`; dropped the
    `dailyNumber` field from the form state entirely.
  + `emptyMeetingForm` / `openEdit` updated; ManageSection.save() and
    the inline ScheduleSection save body now send `isExternal` and
    never send `dailyNumber`.
  + MeetingFormDialog: removed the daily-number FormRow and added a
    Switch-based external-meeting FormRow with localized hint.
  + `rowColors` memo coerces to "red" whenever `m.isExternal`.
  + Audit-diff "interesting" list now includes `isExternal`.
- artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
  + Meeting type gains optional `isExternal`.
  + "Postpone" button disabled + tooltip when meeting is external.
- locales: added `field.isExternal`, `field.isExternalHint`,
  `field.isExternalOn/Off`, and `alert.externalCannotPostpone` in
  both ar.json and en.json. Existing dailyNumber keys kept (still
  referenced by the manage-table column header / cell display).

Post-review hardening (from architect pass)
- PATCH now coerces rowColor → 'red' for any PATCH that touches a row
  that is (or becomes) external. Prevents PDF/export paths that read
  raw rowColor from showing a non-red tint for externals.
- Schedule row quick-actions popover: the "Postpone" button is now
  disabled with a localized tooltip when the meeting is external,
  matching the upcoming-meeting alert behavior.
- PostponeDialog.handleErr maps the 409 code
  `external_meeting_no_auto_postpone` to the localized
  `alert.externalCannotPostpone` toast so users see a real reason
  instead of the raw English error string.

Notes
- The `dailyNumber` column stays in DB (heavily used for slot
  persistence and the unique index); only the form input was removed.
  Server's `nextDailyNumber()` already auto-assigns when the field is
  absent on POST, and PATCH leaves it untouched when absent.
- Two pre-existing TS errors remain in api-server (`isHighlighted`
  type-inference quirk on `z.union(...).transform(...)` and a
  font_settings comparison) — not in scope for this task.
- Tests not added; verification by typecheck on changed surfaces and
  workflow-restart smoke. No e2e harness was wired.
This commit is contained in:
Riyadh
2026-05-25 11:59:54 +00:00
parent 2e6f02fe83
commit 7bfb4afd1a
6 changed files with 135 additions and 19 deletions
@@ -231,6 +231,12 @@ const meetingBaseFields = {
.transform((v) => (v ? 1 : 0)) .transform((v) => (v ? 1 : 0))
.optional(), .optional(),
notes: z.string().trim().max(5000).nullable().optional(), notes: z.string().trim().max(5000).nullable().optional(),
// #635: external-meeting flag. Coerce truthy/falsy → boolean so older
// clients can keep sending 0/1 without surprises.
isExternal: z
.union([z.boolean(), z.number()])
.transform((v) => Boolean(v))
.optional(),
} as const; } as const;
const meetingCreateSchema = z const meetingCreateSchema = z
@@ -282,6 +288,7 @@ const meetingPatchSchema = z
status: meetingBaseFields.status, status: meetingBaseFields.status,
isHighlighted: meetingBaseFields.isHighlighted, isHighlighted: meetingBaseFields.isHighlighted,
notes: meetingBaseFields.notes, notes: meetingBaseFields.notes,
isExternal: meetingBaseFields.isExternal,
attendees: z.array(attendeeSchema).optional(), attendees: z.array(attendeeSchema).optional(),
merge: meetingMergeSchema.optional(), merge: meetingMergeSchema.optional(),
// Row-highlight colour (#288). null = clear back to default (no tint). // Row-highlight colour (#288). null = clear back to default (no tint).
@@ -669,6 +676,12 @@ router.post(
status: data.status ?? "scheduled", status: data.status ?? "scheduled",
isHighlighted: data.isHighlighted ?? 0, isHighlighted: data.isHighlighted ?? 0,
notes: stripTagsToPlainTextOrNull(data.notes), notes: stripTagsToPlainTextOrNull(data.notes),
// #635: persist the external-meeting flag; default false.
// When true, force rowColor='red' so the schedule always
// renders the row in the agreed external-meeting tint even
// if the client forgot to pass it.
isExternal: Boolean(data.isExternal ?? false),
rowColor: data.isExternal ? "red" : (data as { rowColor?: string }).rowColor ?? null,
createdBy: userId, createdBy: userId,
updatedBy: userId, updatedBy: userId,
}; };
@@ -843,6 +856,22 @@ router.patch(
if (data.rowColor !== undefined) { if (data.rowColor !== undefined) {
updateValues.rowColor = data.rowColor; updateValues.rowColor = data.rowColor;
} }
// #635: persist isExternal. When switching to external, also
// force rowColor='red' (overrides any rowColor in the same
// PATCH) so the red tint always tracks the flag.
if (data.isExternal !== undefined) {
updateValues.isExternal = Boolean(data.isExternal);
if (data.isExternal) updateValues.rowColor = "red";
}
// #635: belt-and-suspenders — if the row is (or is becoming)
// external and a non-red rowColor would otherwise be persisted
// by this PATCH, force red. Prevents PDF/export paths that read
// the raw rowColor from showing a non-red tint for externals.
const willBeExternal =
data.isExternal !== undefined ? Boolean(data.isExternal) : existing.isExternal;
if (willBeExternal && updateValues.rowColor !== undefined && updateValues.rowColor !== "red") {
updateValues.rowColor = "red";
}
if (Object.keys(updateValues).length > 1) { if (Object.keys(updateValues).length > 1) {
await tx await tx
@@ -1126,6 +1155,7 @@ async function computeCascadeShift(
startTime: executiveMeetingsTable.startTime, startTime: executiveMeetingsTable.startTime,
endTime: executiveMeetingsTable.endTime, endTime: executiveMeetingsTable.endTime,
status: executiveMeetingsTable.status, status: executiveMeetingsTable.status,
isExternal: executiveMeetingsTable.isExternal,
}) })
.from(executiveMeetingsTable) .from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, date)); .where(eq(executiveMeetingsTable.meetingDate, date));
@@ -1138,6 +1168,10 @@ async function computeCascadeShift(
const sorted = candidates const sorted = candidates
.filter((c) => c.id !== primaryId) .filter((c) => c.id !== primaryId)
.filter((c) => c.status !== "cancelled" && c.status !== "completed") .filter((c) => c.status !== "cancelled" && c.status !== "completed")
// #635: external meetings are excluded from auto-postpone cascades
// by design — they're scheduled with the other party and can't be
// unilaterally shifted.
.filter((c) => !c.isExternal)
.map((c) => ({ .map((c) => ({
...c, ...c,
_startMin: parseTimeToMinutes(c.startTime), _startMin: parseTimeToMinutes(c.startTime),
@@ -1573,6 +1607,18 @@ router.post(
if (!locked) { if (!locked) {
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" }; return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
} }
// #635: External meetings can never be auto-postponed — they're
// scheduled with an outside party and shifting them unilaterally
// would put the schedule out of sync. The UI also disables the
// Postpone button, so this is a belt-and-suspenders 409.
if (locked.isExternal) {
return {
ok: false,
status: 409,
error: "External meetings cannot be auto-postponed",
code: "external_meeting_no_auto_postpone",
};
}
// #283: Optimistic-locking guard. The client always sends the // #283: Optimistic-locking guard. The client always sends the
// `updatedAt` it last observed; if the row has been modified // `updatedAt` it last observed; if the row has been modified
// since, refuse to mutate and return the current state + the // since, refuse to mutate and return the current state + the
@@ -80,6 +80,10 @@ type Meeting = {
// when another user mutated this meeting after it was loaded. Server // when another user mutated this meeting after it was loaded. Server
// sets it on every update; client echoes it back as expectedUpdatedAt. // sets it on every update; client echoes it back as expectedUpdatedAt.
updatedAt: string; updatedAt: string;
// #635: external meeting flag — the alert "Postpone" button is
// disabled when true (the server also rejects with
// `external_meeting_no_auto_postpone`).
isExternal?: boolean;
}; };
type DayResponse = { date: string; meetings: Meeting[] }; type DayResponse = { date: string; meetings: Meeting[] };
type AlertState = { type AlertState = {
@@ -698,7 +702,14 @@ export function UpcomingMeetingAlert() {
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setPostponeOpen(true)} onClick={() => setPostponeOpen(true)}
disabled={savingAction !== null} // #635: external meetings can't be auto-postponed (the
// server also rejects with external_meeting_no_auto_postpone).
disabled={savingAction !== null || meeting.isExternal === true}
title={
meeting.isExternal
? t("executiveMeetings.alert.externalCannotPostpone")
: undefined
}
data-testid="alert-postpone" data-testid="alert-postpone"
> >
{t("executiveMeetings.alert.postpone")} {t("executiveMeetings.alert.postpone")}
@@ -1145,6 +1156,20 @@ function PostponeDialog({
} }
}; };
const handleErr = (err: unknown) => { const handleErr = (err: unknown) => {
// #635: map the external-meeting 409 to the localized reason so
// schedule quick-action callers don't show the raw English error
// from the server.
const code =
err && typeof err === "object" && "code" in err
? (err as { code?: unknown }).code
: undefined;
if (code === "external_meeting_no_auto_postpone") {
toast({
title: t("executiveMeetings.alert.externalCannotPostpone"),
variant: "destructive",
});
return;
}
toast({ toast({
title: t("executiveMeetings.alert.saveFailed"), title: t("executiveMeetings.alert.saveFailed"),
description: err instanceof Error ? err.message : String(err), description: err instanceof Error ? err.message : String(err),
+6 -1
View File
@@ -1246,6 +1246,7 @@
"atTime": "في {{time}}", "atTime": "في {{time}}",
"done": "تم", "done": "تم",
"postpone": "تأجيل", "postpone": "تأجيل",
"externalCannotPostpone": "لا يمكن تأجيل اجتماع خارجي تلقائياً.",
"dismiss": "تجاهل التنبيه", "dismiss": "تجاهل التنبيه",
"dragHandle": "اسحب للتحريك", "dragHandle": "اسحب للتحريك",
"dismissToast": "تم تجاهل التنبيه", "dismissToast": "تم تجاهل التنبيه",
@@ -1474,7 +1475,11 @@
"platform": "المنصة", "platform": "المنصة",
"status": "الحالة", "status": "الحالة",
"isHighlighted": "تمييز (لون أحمر)", "isHighlighted": "تمييز (لون أحمر)",
"notes": "ملاحظات" "notes": "ملاحظات",
"isExternal": "اجتماع خارجي",
"isExternalHint": "يصبح صف الاجتماع باللون الأحمر تلقائياً، ولا يدخل في التأجيل التلقائي.",
"isExternalOn": "نعم",
"isExternalOff": "لا"
}, },
"attendees": { "attendees": {
"label": "قائمة الحضور", "label": "قائمة الحضور",
+6 -1
View File
@@ -1174,6 +1174,7 @@
"atTime": "at {{time}}", "atTime": "at {{time}}",
"done": "Done", "done": "Done",
"postpone": "Postpone", "postpone": "Postpone",
"externalCannotPostpone": "External meetings can't be auto-postponed.",
"dismiss": "Dismiss alert", "dismiss": "Dismiss alert",
"dragHandle": "Drag to move", "dragHandle": "Drag to move",
"dismissToast": "Alert dismissed", "dismissToast": "Alert dismissed",
@@ -1347,7 +1348,11 @@
"platform": "Platform", "platform": "Platform",
"status": "Status", "status": "Status",
"isHighlighted": "Highlight (red)", "isHighlighted": "Highlight (red)",
"notes": "Notes" "notes": "Notes",
"isExternal": "External meeting",
"isExternalHint": "Row turns red automatically and is excluded from auto-postpone.",
"isExternalOn": "Yes",
"isExternalOff": "No"
}, },
"attendees": { "attendees": {
"label": "Attendees", "label": "Attendees",
@@ -194,6 +194,10 @@ type Meeting = {
// is the same for every viewer. NULL/missing = "default" (no tint). // is the same for every viewer. NULL/missing = "default" (no tint).
// Allowed values come from ROW_COLOR_OPTIONS below. // Allowed values come from ROW_COLOR_OPTIONS below.
rowColor?: string | null; rowColor?: string | null;
// #635: External meeting flag. When true, the row is force-tinted red
// and the alert-postpone button is disabled (the server also rejects
// /postpone-minutes with `external_meeting_no_auto_postpone`).
isExternal?: boolean;
// #486: optimistic-lock token used by /executive-meetings/swap-times // #486: optimistic-lock token used by /executive-meetings/swap-times
// (the schedule row quick-actions Move up / Move down popover) and // (the schedule row quick-actions Move up / Move down popover) and
// by the existing PostponeDialog when reused from the schedule. // by the existing PostponeDialog when reused from the schedule.
@@ -1361,7 +1365,12 @@ function ScheduleSection({
const rowColors = useMemo<Record<number, string>>(() => { const rowColors = useMemo<Record<number, string>>(() => {
const out: Record<number, string> = {}; const out: Record<number, string> = {};
for (const m of meetings) { for (const m of meetings) {
if (m.rowColor) out[m.id] = m.rowColor; // #635: external meetings are always tinted red regardless of
// (and overriding) any manually-set rowColor, so the visual cue
// matches the flag even on legacy rows whose rowColor was set
// before isExternal existed.
if (m.isExternal) out[m.id] = "red";
else if (m.rowColor) out[m.id] = m.rowColor;
} }
return out; return out;
}, [meetings]); }, [meetings]);
@@ -3403,10 +3412,9 @@ function ScheduleSection({
sortOrder: idx, sortOrder: idx,
kind: a.kind ?? "person", kind: a.kind ?? "person",
})), })),
// #635: external meeting flag.
isExternal: editingMeeting.isExternal,
}; };
if (editingMeeting.dailyNumber.trim()) {
body.dailyNumber = Number(editingMeeting.dailyNumber);
}
try { try {
await apiJson( await apiJson(
`/api/executive-meetings/${editingMeeting.id}`, `/api/executive-meetings/${editingMeeting.id}`,
@@ -4984,6 +4992,12 @@ function MeetingRow({
setQuickOpen(false); setQuickOpen(false);
onQuickPostpone?.(); onQuickPostpone?.();
}} }}
disabled={meeting.isExternal}
title={
meeting.isExternal
? t("executiveMeetings.alert.externalCannotPostpone")
: undefined
}
className={`flex-1 min-w-0 gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`} className={`flex-1 min-w-0 gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
data-testid={`em-row-quick-postpone-${meeting.id}`} data-testid={`em-row-quick-postpone-${meeting.id}`}
> >
@@ -6266,7 +6280,6 @@ type MeetingFormState = {
titleAr: string; titleAr: string;
titleEn: string; titleEn: string;
meetingDate: string; meetingDate: string;
dailyNumber: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
location: string; location: string;
@@ -6276,6 +6289,9 @@ type MeetingFormState = {
isHighlighted: boolean; isHighlighted: boolean;
notes: string; notes: string;
attendees: Attendee[]; attendees: Attendee[];
// #635: external meeting flag — when true the row is force-tinted red
// and the alert-postpone button is disabled.
isExternal: boolean;
}; };
// Generate a stable client-only attendee id for the manage-dialog DnD. // Generate a stable client-only attendee id for the manage-dialog DnD.
@@ -6294,7 +6310,6 @@ function emptyMeetingForm(date: string): MeetingFormState {
titleAr: "", titleAr: "",
titleEn: "", titleEn: "",
meetingDate: date, meetingDate: date,
dailyNumber: "",
startTime: "", startTime: "",
endTime: "", endTime: "",
location: "", location: "",
@@ -6304,6 +6319,7 @@ function emptyMeetingForm(date: string): MeetingFormState {
isHighlighted: false, isHighlighted: false,
notes: "", notes: "",
attendees: [], attendees: [],
isExternal: false,
}; };
} }
@@ -6602,7 +6618,6 @@ function ManageSection({
titleAr: htmlToPlainText(m.titleAr), titleAr: htmlToPlainText(m.titleAr),
titleEn: htmlToPlainText(m.titleEn ?? ""), titleEn: htmlToPlainText(m.titleEn ?? ""),
meetingDate: m.meetingDate, meetingDate: m.meetingDate,
dailyNumber: String(m.dailyNumber),
startTime: m.startTime ? m.startTime.slice(0, 5) : "", startTime: m.startTime ? m.startTime.slice(0, 5) : "",
endTime: m.endTime ? m.endTime.slice(0, 5) : "", endTime: m.endTime ? m.endTime.slice(0, 5) : "",
location: m.location ?? "", location: m.location ?? "",
@@ -6622,6 +6637,7 @@ function ManageSection({
name: htmlToPlainText(a.name), name: htmlToPlainText(a.name),
_sid: genAttendeeSid(), _sid: genAttendeeSid(),
})), })),
isExternal: m.isExternal ?? false,
}); });
} }
@@ -6675,8 +6691,10 @@ function ManageSection({
// schedule survive a save through the Manage dialog. // schedule survive a save through the Manage dialog.
kind: a.kind ?? "person", kind: a.kind ?? "person",
})), })),
// #635: send the external flag so the server can force rowColor='red'
// and the cascade/postpone rules know to skip it.
isExternal: editing.isExternal,
}; };
if (editing.dailyNumber.trim()) body.dailyNumber = Number(editing.dailyNumber);
try { try {
if (editing.id == null) { if (editing.id == null) {
await apiJson(`/api/executive-meetings`, { method: "POST", body }); await apiJson(`/api/executive-meetings`, { method: "POST", body });
@@ -7553,16 +7571,26 @@ function MeetingFormDialog({
onChange={(e) => update("meetingDate", e.target.value)} onChange={(e) => update("meetingDate", e.target.value)}
/> />
</FormRow> </FormRow>
{/* #635: external-meeting toggle replaces the daily-number
input (numbering is now fully auto-assigned by the
server). When ON the row is force-tinted red and the
alert-postpone button is disabled. */}
<FormRow <FormRow
label={t("executiveMeetings.manage.field.dailyNumber")} label={t("executiveMeetings.manage.field.isExternal")}
hint={t("executiveMeetings.manage.field.dailyNumberHint")} hint={t("executiveMeetings.manage.field.isExternalHint")}
> >
<Input <div className="flex items-center gap-2 h-9">
type="number" <Switch
min={1} checked={state.isExternal}
value={state.dailyNumber} onCheckedChange={(v) => update("isExternal", Boolean(v))}
onChange={(e) => update("dailyNumber", e.target.value)} data-testid="em-form-isExternal"
/> />
<span className="text-xs text-gray-700">
{state.isExternal
? t("executiveMeetings.manage.field.isExternalOn")
: t("executiveMeetings.manage.field.isExternalOff")}
</span>
</div>
</FormRow> </FormRow>
<FormRow <FormRow
label={t("executiveMeetings.manage.field.startTime")} label={t("executiveMeetings.manage.field.startTime")}
@@ -7843,6 +7871,7 @@ function AuditDiffSummary({
"platform", "platform",
"status", "status",
"isHighlighted", "isHighlighted",
"isExternal",
"notes", "notes",
"assignedTo", "assignedTo",
"taskType", "taskType",
+6
View File
@@ -73,6 +73,12 @@ export const executiveMeetingsTable = pgTable(
// signal about the meeting (urgent / VIP / etc.) rather than a // signal about the meeting (urgent / VIP / etc.) rather than a
// personal viewing preference. // personal viewing preference.
rowColor: varchar("row_color", { length: 16 }), rowColor: varchar("row_color", { length: 16 }),
// #635: External meetings (اجتماع خارجي). When true, the row is
// tinted red in the schedule grid and is excluded from the
// auto-postpone cascade (the alert "Postpone" button is disabled
// and `computeCascadeShift` skips it as a follower). Default false
// preserves legacy rows as "internal" meetings.
isExternal: boolean("is_external").notNull().default(false),
createdBy: integer("created_by").references(() => usersTable.id, { createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null", onDelete: "set null",
}), }),