#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:
riyadhafraa
2026-05-25 11:59:54 +00:00
parent 5dc42fdb81
commit 736785e3d3
6 changed files with 135 additions and 19 deletions
@@ -231,6 +231,12 @@ const meetingBaseFields = {
.transform((v) => (v ? 1 : 0))
.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;
const meetingCreateSchema = z
@@ -282,6 +288,7 @@ const meetingPatchSchema = z
status: meetingBaseFields.status,
isHighlighted: meetingBaseFields.isHighlighted,
notes: meetingBaseFields.notes,
isExternal: meetingBaseFields.isExternal,
attendees: z.array(attendeeSchema).optional(),
merge: meetingMergeSchema.optional(),
// Row-highlight colour (#288). null = clear back to default (no tint).
@@ -669,6 +676,12 @@ router.post(
status: data.status ?? "scheduled",
isHighlighted: data.isHighlighted ?? 0,
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,
updatedBy: userId,
};
@@ -843,6 +856,22 @@ router.patch(
if (data.rowColor !== undefined) {
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) {
await tx
@@ -1126,6 +1155,7 @@ async function computeCascadeShift(
startTime: executiveMeetingsTable.startTime,
endTime: executiveMeetingsTable.endTime,
status: executiveMeetingsTable.status,
isExternal: executiveMeetingsTable.isExternal,
})
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, date));
@@ -1138,6 +1168,10 @@ async function computeCascadeShift(
const sorted = candidates
.filter((c) => c.id !== primaryId)
.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) => ({
...c,
_startMin: parseTimeToMinutes(c.startTime),
@@ -1573,6 +1607,18 @@ router.post(
if (!locked) {
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
// `updatedAt` it last observed; if the row has been modified
// 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
// sets it on every update; client echoes it back as expectedUpdatedAt.
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 AlertState = {
@@ -698,7 +702,14 @@ export function UpcomingMeetingAlert() {
size="sm"
variant="outline"
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"
>
{t("executiveMeetings.alert.postpone")}
@@ -1145,6 +1156,20 @@ function PostponeDialog({
}
};
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({
title: t("executiveMeetings.alert.saveFailed"),
description: err instanceof Error ? err.message : String(err),
+6 -1
View File
@@ -1246,6 +1246,7 @@
"atTime": "في {{time}}",
"done": "تم",
"postpone": "تأجيل",
"externalCannotPostpone": "لا يمكن تأجيل اجتماع خارجي تلقائياً.",
"dismiss": "تجاهل التنبيه",
"dragHandle": "اسحب للتحريك",
"dismissToast": "تم تجاهل التنبيه",
@@ -1474,7 +1475,11 @@
"platform": "المنصة",
"status": "الحالة",
"isHighlighted": "تمييز (لون أحمر)",
"notes": "ملاحظات"
"notes": "ملاحظات",
"isExternal": "اجتماع خارجي",
"isExternalHint": "يصبح صف الاجتماع باللون الأحمر تلقائياً، ولا يدخل في التأجيل التلقائي.",
"isExternalOn": "نعم",
"isExternalOff": "لا"
},
"attendees": {
"label": "قائمة الحضور",
+6 -1
View File
@@ -1174,6 +1174,7 @@
"atTime": "at {{time}}",
"done": "Done",
"postpone": "Postpone",
"externalCannotPostpone": "External meetings can't be auto-postponed.",
"dismiss": "Dismiss alert",
"dragHandle": "Drag to move",
"dismissToast": "Alert dismissed",
@@ -1347,7 +1348,11 @@
"platform": "Platform",
"status": "Status",
"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": {
"label": "Attendees",
@@ -194,6 +194,10 @@ type Meeting = {
// is the same for every viewer. NULL/missing = "default" (no tint).
// Allowed values come from ROW_COLOR_OPTIONS below.
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
// (the schedule row quick-actions Move up / Move down popover) and
// by the existing PostponeDialog when reused from the schedule.
@@ -1361,7 +1365,12 @@ function ScheduleSection({
const rowColors = useMemo<Record<number, string>>(() => {
const out: Record<number, string> = {};
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;
}, [meetings]);
@@ -3403,10 +3412,9 @@ function ScheduleSection({
sortOrder: idx,
kind: a.kind ?? "person",
})),
// #635: external meeting flag.
isExternal: editingMeeting.isExternal,
};
if (editingMeeting.dailyNumber.trim()) {
body.dailyNumber = Number(editingMeeting.dailyNumber);
}
try {
await apiJson(
`/api/executive-meetings/${editingMeeting.id}`,
@@ -4984,6 +4992,12 @@ function MeetingRow({
setQuickOpen(false);
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" : ""}`}
data-testid={`em-row-quick-postpone-${meeting.id}`}
>
@@ -6266,7 +6280,6 @@ type MeetingFormState = {
titleAr: string;
titleEn: string;
meetingDate: string;
dailyNumber: string;
startTime: string;
endTime: string;
location: string;
@@ -6276,6 +6289,9 @@ type MeetingFormState = {
isHighlighted: boolean;
notes: string;
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.
@@ -6294,7 +6310,6 @@ function emptyMeetingForm(date: string): MeetingFormState {
titleAr: "",
titleEn: "",
meetingDate: date,
dailyNumber: "",
startTime: "",
endTime: "",
location: "",
@@ -6304,6 +6319,7 @@ function emptyMeetingForm(date: string): MeetingFormState {
isHighlighted: false,
notes: "",
attendees: [],
isExternal: false,
};
}
@@ -6602,7 +6618,6 @@ function ManageSection({
titleAr: htmlToPlainText(m.titleAr),
titleEn: htmlToPlainText(m.titleEn ?? ""),
meetingDate: m.meetingDate,
dailyNumber: String(m.dailyNumber),
startTime: m.startTime ? m.startTime.slice(0, 5) : "",
endTime: m.endTime ? m.endTime.slice(0, 5) : "",
location: m.location ?? "",
@@ -6622,6 +6637,7 @@ function ManageSection({
name: htmlToPlainText(a.name),
_sid: genAttendeeSid(),
})),
isExternal: m.isExternal ?? false,
});
}
@@ -6675,8 +6691,10 @@ function ManageSection({
// schedule survive a save through the Manage dialog.
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 {
if (editing.id == null) {
await apiJson(`/api/executive-meetings`, { method: "POST", body });
@@ -7553,16 +7571,26 @@ function MeetingFormDialog({
onChange={(e) => update("meetingDate", e.target.value)}
/>
</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
label={t("executiveMeetings.manage.field.dailyNumber")}
hint={t("executiveMeetings.manage.field.dailyNumberHint")}
label={t("executiveMeetings.manage.field.isExternal")}
hint={t("executiveMeetings.manage.field.isExternalHint")}
>
<Input
type="number"
min={1}
value={state.dailyNumber}
onChange={(e) => update("dailyNumber", e.target.value)}
/>
<div className="flex items-center gap-2 h-9">
<Switch
checked={state.isExternal}
onCheckedChange={(v) => update("isExternal", Boolean(v))}
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
label={t("executiveMeetings.manage.field.startTime")}
@@ -7843,6 +7871,7 @@ function AuditDiffSummary({
"platform",
"status",
"isHighlighted",
"isExternal",
"notes",
"assignedTo",
"taskType",
+6
View File
@@ -73,6 +73,12 @@ export const executiveMeetingsTable = pgTable(
// signal about the meeting (urgent / VIP / etc.) rather than a
// personal viewing preference.
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, {
onDelete: "set null",
}),