From 736785e3d3ce0436d82aae2903e21673c5520c60 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 25 May 2026 11:59:54 +0000 Subject: [PATCH] #635: External meetings + auto-numbering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/routes/executive-meetings.ts | 46 ++++++++++++++ .../upcoming-meeting-alert.tsx | 27 +++++++- artifacts/tx-os/src/locales/ar.json | 7 ++- artifacts/tx-os/src/locales/en.json | 7 ++- .../tx-os/src/pages/executive-meetings.tsx | 61 ++++++++++++++----- lib/db/src/schema/executive-meetings.ts | 6 ++ 6 files changed, 135 insertions(+), 19 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 9cfc8e7f..09b5e252 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -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 diff --git a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx index 7c324c07..79c8b806 100644 --- a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx +++ b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx @@ -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), diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 360dd5dd..14057ab7 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "قائمة الحضور", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index c6f3c55f..d80430a0 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index dd3ae1bd..271c8574 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -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>(() => { const out: Record = {}; 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)} /> + {/* #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. */} - update("dailyNumber", e.target.value)} - /> +
+ update("isExternal", Boolean(v))} + data-testid="em-form-isExternal" + /> + + {state.isExternal + ? t("executiveMeetings.manage.field.isExternalOn") + : t("executiveMeetings.manage.field.isExternalOff")} + +
usersTable.id, { onDelete: "set null", }),