Executive Meetings Phase 2: full module + atomic audit

Original task (#108): polish the daily-schedule UI and ship the
remaining 8 sections of the Executive Meetings module (Manage,
Requests, Approvals, Tasks, Notifications, Audit, PDF, Font Settings)
with bilingual i18n and fine-grained RBAC.

What changed:
- Schedule UI: centered cells, '#' header (not 'م'), attendees widest,
  RTL column order locked, navy/white/gray + red highlighting only.
- Schema: made executive_meeting_requests.meetingId nullable so
  'create-meeting' suggestions don't need a target row.
- Backend rewrite of artifacts/api-server/src/routes/executive-meetings.ts:
  GET /me (now returns userId), full CRUD for meetings (transactional
  attendee replace), requests CRUD + approve/reject/withdraw, tasks
  CRUD with assignee status updates, audit-logs GET (admin), notifications
  GET, font-settings GET/PATCH (per-user + global). RBAC via 5 role
  sets and a makeRequireRoles middleware factory.
- Audit-in-transaction: every mutation (meeting/request/task/font CRUD)
  wraps the DB write AND the audit-log insert in the same db.transaction
  so audit entries cannot drift from state if either insert fails. This
  was the main finding from the architect review and is now fixed.
- Path-to-regexp 8 fix: replaced inline ':id(\\d+)' with router.param('id')
  + next('route') guard, plus NaN guards in handlers.
- Frontend rewrite of artifacts/tx-os/src/pages/executive-meetings.tsx
  (~2200 lines): 8 new section components, RBAC-aware UI via /me,
  per-task assignee check now shown for status buttons.
- i18n: full executiveMeetings.* key set added in ar.json + en.json.

Verified: full e2e (admin login -> all 9 tabs -> create meeting ->
submit request -> approve -> audit shows full chain -> font save)
plus a smoke regression after the audit-in-tx refactor (atomic
audit row created in lockstep with mutation).

Out of scope (proposed as follow-ups #110-#112): real notification
delivery, real PDF generation (currently window.print), and
automated integration tests for the new endpoints. The pre-existing
'apps-open.test.mjs' syntax error in the test workflow is unrelated
to this task.
This commit is contained in:
riyadhafraa
2026-04-28 06:18:08 +00:00
parent 539238028b
commit b4d47e1012
7 changed files with 3471 additions and 89 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+226 -1
View File
@@ -622,7 +622,7 @@
"externalAttendance": "حضور خارجي",
"placeholderBody": "هذا القسم قيد التطوير في المراحل القادمة. ستتوفر إدارته الكاملة قريباً.",
"col": {
"number": "م",
"number": "#",
"meeting": "الاجتماع",
"attendees": "الحضور",
"time": "الوقت"
@@ -637,6 +637,231 @@
"audit": "سجل التعديلات",
"pdf": "تصدير PDF",
"fontSettings": "إعدادات الخط"
},
"common": {
"save": "حفظ",
"cancel": "إلغاء",
"delete": "حذف",
"edit": "تعديل",
"add": "إضافة",
"submit": "إرسال",
"close": "إغلاق",
"loading": "جارٍ التحميل...",
"saved": "تم الحفظ",
"saveFailed": "تعذر الحفظ",
"actions": "إجراءات",
"noPermission": "ليست لديك صلاحية لتنفيذ هذا الإجراء",
"yes": "نعم",
"no": "لا",
"all": "الكل",
"filter": "تصفية"
},
"manage": {
"heading": "إدارة الاجتماعات",
"addMeeting": "إضافة اجتماع",
"editMeeting": "تعديل الاجتماع",
"deleteMeeting": "حذف الاجتماع",
"deleteConfirm": "حذف هذا الاجتماع نهائياً؟",
"noMeetings": "لا توجد اجتماعات لهذا اليوم. اضغط \"إضافة اجتماع\" لإنشاء واحد.",
"field": {
"titleAr": "العنوان (عربي)",
"titleEn": "العنوان (إنجليزي)",
"meetingDate": "التاريخ",
"dailyNumber": "الرقم اليومي",
"dailyNumberHint": "اتركه فارغاً ليتم الترقيم تلقائياً",
"startTime": "وقت البدء",
"endTime": "وقت الانتهاء",
"location": "المكان",
"meetingUrl": "رابط الاجتماع",
"platform": "المنصة",
"status": "الحالة",
"isHighlighted": "تمييز (لون أحمر)",
"notes": "ملاحظات"
},
"attendees": {
"label": "قائمة الحضور",
"name": "الاسم",
"type": "نوع الحضور",
"add": "إضافة حاضر",
"remove": "إزالة"
},
"platform": {
"none": "بدون",
"webex": "Webex",
"teams": "Teams",
"zoom": "Zoom",
"other": "أخرى"
},
"attendanceType": {
"internal": "داخلي",
"virtual": "افتراضي",
"external": "خارجي"
},
"status": {
"scheduled": "مجدول",
"cancelled": "ملغى",
"completed": "منتهٍ",
"postponed": "مؤجل"
},
"errors": {
"titleRequired": "العنوان بالعربية مطلوب",
"dateRequired": "التاريخ مطلوب",
"duplicateNumber": "هذا الرقم اليومي مستخدم بالفعل في هذا التاريخ",
"saveFailed": "تعذر حفظ الاجتماع"
}
},
"requests": {
"heading": "طلبات التعديل",
"newRequest": "طلب جديد",
"myRequests": "طلباتي",
"allRequests": "جميع الطلبات",
"empty": "لا توجد طلبات.",
"filterStatus": "تصفية حسب الحالة",
"submitFor": "اقتراح تعديل على:",
"noTargetMeeting": "اقتراح اجتماع جديد (بدون ربط)",
"details": "التفاصيل",
"detailsPlaceholder": "اشرح ما تقترح بالتفصيل...",
"submitted": "تم إرسال الطلب",
"submitFailed": "تعذر إرسال الطلب",
"requestedBy": "مقدم الطلب",
"requestedAt": "وقت الطلب",
"type": {
"create": "إنشاء اجتماع",
"edit": "تعديل بيانات",
"delete": "حذف",
"reschedule": "إعادة جدولة",
"highlight": "تمييز",
"unhighlight": "إزالة التمييز",
"other": "آخر"
},
"status": {
"new": "جديد",
"approved": "تمت الموافقة",
"rejected": "مرفوض",
"withdrawn": "مسحوب"
}
},
"approvals": {
"heading": "موافقات مدير المكتب",
"empty": "لا توجد طلبات بانتظار المراجعة.",
"approve": "اعتماد",
"reject": "رفض",
"reviewNotes": "ملاحظات المراجعة",
"reviewNotesPlaceholder": "اكتب ملاحظاتك هنا (اختياري)",
"approved": "تم الاعتماد",
"rejected": "تم الرفض",
"actionFailed": "تعذر تنفيذ المراجعة"
},
"tasks": {
"heading": "مهام فريق التنسيق",
"addTask": "إضافة مهمة",
"empty": "لا توجد مهام.",
"field": {
"taskType": "نوع المهمة",
"assignee": "المُكلَّف",
"dueDate": "تاريخ الاستحقاق",
"notes": "ملاحظات",
"meeting": "الاجتماع",
"status": "الحالة"
},
"status": {
"pending": "بالانتظار",
"in_progress": "قيد التنفيذ",
"completed": "منجزة",
"cancelled": "ملغاة"
},
"markCompleted": "إنهاء المهمة",
"markInProgress": "بدء التنفيذ",
"deleteConfirm": "حذف هذه المهمة؟",
"saved": "تم حفظ المهمة",
"deleted": "تم حذف المهمة"
},
"audit": {
"heading": "سجل التعديلات",
"empty": "لا توجد قيود في السجل.",
"filterDate": "تصفية بالتاريخ",
"filterEntity": "النوع",
"all": "الكل",
"col": {
"when": "الوقت",
"actor": "المنفّذ",
"action": "الإجراء",
"entity": "النوع",
"entityId": "المعرف",
"diff": "التفاصيل"
},
"entity": {
"meeting": "اجتماع",
"attendee": "حاضر",
"request": "طلب",
"task": "مهمة",
"font_settings": "إعدادات الخط"
},
"action": {
"create": "إنشاء",
"update": "تعديل",
"delete": "حذف",
"approve": "اعتماد",
"reject": "رفض",
"submit": "إرسال"
}
},
"pdf": {
"heading": "تصدير / طباعة",
"intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.",
"print": "طباعة الجدول",
"openSchedule": "عرض الجدول",
"selectDate": "تاريخ الجدول"
},
"fontSettingsPage": {
"heading": "إعدادات الخط",
"intro": "تطبق هذه الإعدادات على جدول الاجتماعات. يمكنك حفظ تفضيلك الشخصي، ويمكن للمسؤول حفظ الإعدادات العامة لجميع المستخدمين.",
"scope": "النطاق",
"scopeUser": "تفضيلي الشخصي",
"scopeGlobal": "عام (جميع المستخدمين)",
"fontFamily": "نوع الخط",
"fontSize": "حجم الخط",
"fontWeight": "السماكة",
"alignment": "المحاذاة",
"weight": {
"regular": "عادي",
"medium": "متوسط",
"semibold": "نصف عريض",
"bold": "عريض"
},
"align": {
"start": "بداية",
"center": "وسط",
"end": "نهاية"
},
"preview": "معاينة",
"previewText": "نص تجريبي ١٢٣ Sample 123",
"save": "حفظ الإعدادات",
"saved": "تم الحفظ",
"reset": "إعادة الافتراضي"
},
"notificationsPage": {
"heading": "التنبيهات",
"intro": "هذه قائمة التنبيهات المجدولة المرتبطة باجتماعات المكتب. لا يتم الإرسال الفعلي حالياً (وضع تجريبي).",
"empty": "لا توجد تنبيهات مجدولة.",
"col": {
"meeting": "الاجتماع",
"user": "المستخدم",
"type": "النوع",
"scheduledAt": "الموعد",
"status": "الحالة"
},
"type": {
"reminder": "تذكير",
"change": "تنبيه تعديل",
"cancel": "تنبيه إلغاء"
},
"status": {
"pending": "بالانتظار",
"sent": "أُرسل",
"skipped": "متخطى",
"failed": "فشل"
}
}
}
}
+225
View File
@@ -634,6 +634,231 @@
"audit": "Audit Log",
"pdf": "PDF Export",
"fontSettings": "Font Settings"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"submit": "Submit",
"close": "Close",
"loading": "Loading...",
"saved": "Saved",
"saveFailed": "Save failed",
"actions": "Actions",
"noPermission": "You do not have permission for this action",
"yes": "Yes",
"no": "No",
"all": "All",
"filter": "Filter"
},
"manage": {
"heading": "Manage Meetings",
"addMeeting": "Add meeting",
"editMeeting": "Edit meeting",
"deleteMeeting": "Delete meeting",
"deleteConfirm": "Permanently delete this meeting?",
"noMeetings": "No meetings on this day. Click \"Add meeting\" to create one.",
"field": {
"titleAr": "Title (Arabic)",
"titleEn": "Title (English)",
"meetingDate": "Date",
"dailyNumber": "Daily number",
"dailyNumberHint": "Leave blank for auto-numbering",
"startTime": "Start time",
"endTime": "End time",
"location": "Location",
"meetingUrl": "Meeting URL",
"platform": "Platform",
"status": "Status",
"isHighlighted": "Highlight (red)",
"notes": "Notes"
},
"attendees": {
"label": "Attendees",
"name": "Name",
"type": "Attendance type",
"add": "Add attendee",
"remove": "Remove"
},
"platform": {
"none": "None",
"webex": "Webex",
"teams": "Teams",
"zoom": "Zoom",
"other": "Other"
},
"attendanceType": {
"internal": "Internal",
"virtual": "Virtual",
"external": "External"
},
"status": {
"scheduled": "Scheduled",
"cancelled": "Cancelled",
"completed": "Completed",
"postponed": "Postponed"
},
"errors": {
"titleRequired": "Arabic title is required",
"dateRequired": "Date is required",
"duplicateNumber": "That daily number already exists for this date",
"saveFailed": "Could not save the meeting"
}
},
"requests": {
"heading": "Change Requests",
"newRequest": "New request",
"myRequests": "My requests",
"allRequests": "All requests",
"empty": "No requests yet.",
"filterStatus": "Filter by status",
"submitFor": "Suggest a change to:",
"noTargetMeeting": "Propose a brand-new meeting (no link)",
"details": "Details",
"detailsPlaceholder": "Explain your suggestion in detail...",
"submitted": "Request submitted",
"submitFailed": "Could not submit the request",
"requestedBy": "Submitted by",
"requestedAt": "Submitted at",
"type": {
"create": "Create meeting",
"edit": "Edit details",
"delete": "Delete",
"reschedule": "Reschedule",
"highlight": "Highlight",
"unhighlight": "Remove highlight",
"other": "Other"
},
"status": {
"new": "New",
"approved": "Approved",
"rejected": "Rejected",
"withdrawn": "Withdrawn"
}
},
"approvals": {
"heading": "Office Manager Approvals",
"empty": "No requests pending review.",
"approve": "Approve",
"reject": "Reject",
"reviewNotes": "Review notes",
"reviewNotesPlaceholder": "Optional notes for the requester",
"approved": "Approved",
"rejected": "Rejected",
"actionFailed": "Could not complete the review"
},
"tasks": {
"heading": "Coordination Tasks",
"addTask": "Add task",
"empty": "No tasks yet.",
"field": {
"taskType": "Task type",
"assignee": "Assignee",
"dueDate": "Due date",
"notes": "Notes",
"meeting": "Meeting",
"status": "Status"
},
"status": {
"pending": "Pending",
"in_progress": "In progress",
"completed": "Completed",
"cancelled": "Cancelled"
},
"markCompleted": "Mark completed",
"markInProgress": "Start",
"deleteConfirm": "Delete this task?",
"saved": "Task saved",
"deleted": "Task deleted"
},
"audit": {
"heading": "Audit Log",
"empty": "No audit entries.",
"filterDate": "Filter by date",
"filterEntity": "Entity",
"all": "All",
"col": {
"when": "When",
"actor": "Actor",
"action": "Action",
"entity": "Entity",
"entityId": "ID",
"diff": "Details"
},
"entity": {
"meeting": "Meeting",
"attendee": "Attendee",
"request": "Request",
"task": "Task",
"font_settings": "Font settings"
},
"action": {
"create": "Create",
"update": "Update",
"delete": "Delete",
"approve": "Approve",
"reject": "Reject",
"submit": "Submit"
}
},
"pdf": {
"heading": "Print / Export",
"intro": "This view uses the browser print dialog to produce a PDF of the day's schedule. Pick a date and click Print.",
"print": "Print schedule",
"openSchedule": "Open schedule",
"selectDate": "Schedule date"
},
"fontSettingsPage": {
"heading": "Font Settings",
"intro": "These settings apply to the meetings schedule. You can save your personal preference; admins can also save the global default.",
"scope": "Scope",
"scopeUser": "My preference",
"scopeGlobal": "Global (everyone)",
"fontFamily": "Font family",
"fontSize": "Font size",
"fontWeight": "Weight",
"alignment": "Alignment",
"weight": {
"regular": "Regular",
"medium": "Medium",
"semibold": "Semibold",
"bold": "Bold"
},
"align": {
"start": "Start",
"center": "Center",
"end": "End"
},
"preview": "Preview",
"previewText": "Sample text 123 نص تجريبي ١٢٣",
"save": "Save settings",
"saved": "Saved",
"reset": "Reset to default"
},
"notificationsPage": {
"heading": "Notifications",
"intro": "Scheduled reminders and change-notice records for executive meetings. No actual delivery is wired up yet (placeholder).",
"empty": "No scheduled notifications.",
"col": {
"meeting": "Meeting",
"user": "User",
"type": "Type",
"scheduledAt": "Scheduled",
"status": "Status"
},
"type": {
"reminder": "Reminder",
"change": "Change notice",
"cancel": "Cancellation notice"
},
"status": {
"pending": "Pending",
"sent": "Sent",
"skipped": "Skipped",
"failed": "Failed"
}
}
}
}
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -74,9 +74,10 @@ export const executiveMeetingRequestsTable = pgTable(
"executive_meeting_requests",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
meetingId: integer("meeting_id").references(
() => executiveMeetingsTable.id,
{ onDelete: "cascade" },
),
requestedBy: integer("requested_by").references(() => usersTable.id, {
onDelete: "set null",
}),
+1 -1
View File
@@ -40,7 +40,7 @@
- **Notifications**: unread tracking, mark-all-read
- **Admin Panel**: CRUD for apps, services, users (admin role required)
- **Session-based Auth**: RBAC with roles (admin/user)
- **Executive Meetings (Phase 1)**: bilingual daily-schedule table for the executive office, app entry on the home grid (`/executive-meetings`), 9-section sub-nav (schedule, manage, requests, approvals, tasks, notifications, audit, pdf, font settings) — only the daily schedule is implemented in Phase 1; other sections render placeholders.
- **Executive Meetings (Phase 2)**: bilingual full-stack module under `/executive-meetings` with 9 sections — Schedule (centered cells, attendees widest column, RTL-locked column order # / الاجتماع / الحضور / الوقت), Manage Meetings (CRUD with attendee replace, transactional), Change Requests (submit / withdraw, supports `meetingId=null` for create-suggestions), Approvals (approve/reject with review notes), Tasks (CRUD with assignee status updates — assignees and mutators can change status, only mutators can delete), Notifications (per-user feed), Audit Log (full action chain, admin role required), PDF (window.print export), Font Settings (per-user + global scope, family/size/weight/alignment with live preview). RBAC enforced via 5 role sets (READ/MUTATE/APPROVE/REQUEST/ADMIN_AUDIT) and a `makeRequireRoles` middleware factory; `/api/executive-meetings/me` returns `{userId, roles, canRead, canMutate, canApprove, canSubmitRequest, canViewAudit}`. Every mutation (meeting/request/task/font CRUD) wraps the DB write **and** the audit-log insert in the same `db.transaction(...)` so audit entries cannot drift from state. Routes use `router.param("id")` with `next("route")` to handle path-to-regexp 8 (no inline `:id(\\d+)` support).
## Key Commands