#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:
@@ -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),
|
||||
|
||||
@@ -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": "قائمة الحضور",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user