Add Public Relations & Protocol module (Task #649)
New, fully separate app module "العلاقات العامة والمراسم / Public Relations and Protocol". Does not touch executive-meetings; all new tables are prefixed protocol_ and are additive-only, routes under /protocol. Includes: - 6 protocol_ tables (rooms, room bookings, external meetings, gifts, gift issues, audit log) in lib/db. - API routes + role-scoped middleware (protocol_super_admin, pr_manager, officer, requester, viewer) with requireProtocolAccess gating. - Room-booking conflict prevention (overlap rule newStart<existingEnd AND newEnd>existingStart vs pending/approved) with AR error message. - Gifts/shields (دروع) registry + issuance with stock tracking. - Bilingual AR/EN frontend page (tabs + dialogs) at /protocol, app tile, i18n keys, and seed data (roles, permission, default rooms). Concurrency/security hardening from code review: - All role guards now validate users.isActive (inactive-user bypass fix). - Per-room SELECT ... FOR UPDATE lock serializes booking check-then-write. - Approve paths re-read inside the transaction and use state-conditional updates (WHERE ... AND status='pending' RETURNING). - Gift issuance claims the issue conditionally, then does an atomic conditional stock decrement; failure rolls back the claim. Verified: drizzle push (6 tables), seed, frontend + api-server typecheck (only pre-existing errors in untouched files), unauth gating returns 401, conflict predicate confirmed. Architect review PASS.
This commit is contained in:
@@ -253,3 +253,52 @@ export async function requireExecutiveAccess(
|
||||
next();
|
||||
}
|
||||
|
||||
const PROTOCOL_READ_ROLES: ReadonlyArray<string> = [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
"protocol_officer",
|
||||
"protocol_requester",
|
||||
"protocol_viewer",
|
||||
];
|
||||
|
||||
/**
|
||||
* Gate any read access to the Public Relations & Protocol module. Allows
|
||||
* admin and any of the protocol_* roles. Finer-grained mutate / approve
|
||||
* sub-roles are layered on top via local helpers in the routes file.
|
||||
*/
|
||||
export async function requireProtocolAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
if (!req.session.userId) {
|
||||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
const [user] = await db
|
||||
.select({ isActive: usersTable.isActive })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, req.session.userId));
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
const ids = await getEffectiveRoleIds(req.session.userId);
|
||||
if (ids.length === 0) {
|
||||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||
return;
|
||||
}
|
||||
const rows = await db
|
||||
.select({ name: rolesTable.name })
|
||||
.from(rolesTable)
|
||||
.where(inArray(rolesTable.id, ids));
|
||||
const names = new Set(rows.map((r) => r.name));
|
||||
const ok = PROTOCOL_READ_ROLES.some((r) => names.has(r));
|
||||
if (!ok) {
|
||||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import groupsRouter from "./groups";
|
||||
import rolesRouter from "./roles";
|
||||
import auditRouter from "./audit";
|
||||
import executiveMeetingsRouter from "./executive-meetings";
|
||||
import protocolRouter from "./protocol";
|
||||
import pushRouter from "./push";
|
||||
import systemRouter from "./system";
|
||||
|
||||
@@ -38,6 +39,7 @@ router.use(groupsRouter);
|
||||
router.use(rolesRouter);
|
||||
router.use(auditRouter);
|
||||
router.use(executiveMeetingsRouter);
|
||||
router.use(protocolRouter);
|
||||
router.use(pushRouter);
|
||||
router.use(systemRouter);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import NotesPage from "@/pages/notes";
|
||||
import MyOrdersPage from "@/pages/my-orders";
|
||||
import OrdersIncomingPage from "@/pages/orders-incoming";
|
||||
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
|
||||
import ProtocolPage from "@/pages/protocol";
|
||||
import EmbeddedAppPage from "@/pages/embedded-app";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -73,6 +74,7 @@ function Router() {
|
||||
<Route path="/my-orders" component={MyOrdersPage} />
|
||||
<Route path="/orders/incoming" component={OrdersIncomingPage} />
|
||||
<Route path="/meetings" component={ExecutiveMeetingsPage} />
|
||||
<Route path="/protocol" component={ProtocolPage} />
|
||||
{/* Back-compat redirect: the page used to live at
|
||||
/executive-meetings. Stored PDFs, email links, and bookmarks
|
||||
still point there, so catch the old path (and any deep
|
||||
|
||||
@@ -971,7 +971,9 @@
|
||||
"appName": "نظام Tx",
|
||||
"previous": "السابق",
|
||||
"next": "التالي",
|
||||
"empty": "(فارغ)"
|
||||
"empty": "(فارغ)",
|
||||
"back": "رجوع",
|
||||
"confirmDelete": "تأكيد الحذف"
|
||||
},
|
||||
"embeddedFrame": {
|
||||
"title": "تطبيق مدمج",
|
||||
@@ -1656,5 +1658,111 @@
|
||||
"saved": "تم الحفظ",
|
||||
"reset": "إعادة الافتراضي"
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"title": "العلاقات العامة والمراسم",
|
||||
"notes": "ملاحظات",
|
||||
"statusLabel": "الحالة",
|
||||
"confirmReject": "هل أنت متأكد من رفض هذا الطلب؟",
|
||||
"confirmDelete": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"tabs": {
|
||||
"dashboard": "لوحة المعلومات",
|
||||
"bookings": "حجوزات القاعات",
|
||||
"external": "اللقاءات الخارجية",
|
||||
"gifts": "الهدايا والدروع",
|
||||
"issues": "طلبات الصرف",
|
||||
"reports": "التقارير",
|
||||
"audit": "سجل النشاط"
|
||||
},
|
||||
"status": {
|
||||
"pending": "قيد الانتظار",
|
||||
"approved": "معتمد",
|
||||
"rejected": "مرفوض",
|
||||
"cancelled": "ملغى",
|
||||
"issued": "تم الصرف",
|
||||
"scheduled": "مجدول",
|
||||
"completed": "منجز"
|
||||
},
|
||||
"giftKind": {
|
||||
"gift": "هدية",
|
||||
"shield": "درع"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "اعتماد",
|
||||
"reject": "رفض",
|
||||
"cancel": "إلغاء",
|
||||
"approveIssue": "اعتماد وصرف"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "تعذّر إتمام العملية."
|
||||
},
|
||||
"dashboard": {
|
||||
"activeRooms": "القاعات المتاحة",
|
||||
"todayBookings": "حجوزات اليوم",
|
||||
"pendingBookings": "حجوزات بانتظار الاعتماد",
|
||||
"upcomingExternal": "لقاءات خارجية قادمة",
|
||||
"giftStock": "إجمالي مخزون الهدايا",
|
||||
"pendingIssues": "طلبات صرف معلّقة"
|
||||
},
|
||||
"bookings": {
|
||||
"new": "حجز جديد",
|
||||
"edit": "تعديل الحجز",
|
||||
"empty": "لا توجد حجوزات.",
|
||||
"requester": "مقدّم الطلب",
|
||||
"roomLabel": "القاعة",
|
||||
"titleLabel": "عنوان الحجز",
|
||||
"startsAt": "من",
|
||||
"endsAt": "إلى"
|
||||
},
|
||||
"external": {
|
||||
"new": "لقاء جديد",
|
||||
"edit": "تعديل اللقاء",
|
||||
"empty": "لا توجد لقاءات خارجية.",
|
||||
"titleLabel": "عنوان اللقاء",
|
||||
"party": "الجهة",
|
||||
"location": "المكان",
|
||||
"startsAt": "التاريخ والوقت"
|
||||
},
|
||||
"gifts": {
|
||||
"new": "صنف جديد",
|
||||
"edit": "تعديل الصنف",
|
||||
"empty": "لا توجد أصناف.",
|
||||
"stock": "المخزون",
|
||||
"nameAr": "الاسم (عربي)",
|
||||
"nameEn": "الاسم (إنجليزي)",
|
||||
"kind": "النوع"
|
||||
},
|
||||
"rooms": {
|
||||
"new": "قاعة جديدة",
|
||||
"edit": "تعديل القاعة",
|
||||
"title": "القاعات",
|
||||
"inactive": "غير مفعّلة",
|
||||
"nameAr": "الاسم (عربي)",
|
||||
"nameEn": "الاسم (إنجليزي)",
|
||||
"capacity": "السعة",
|
||||
"location": "الموقع"
|
||||
},
|
||||
"issues": {
|
||||
"new": "طلب صرف",
|
||||
"empty": "لا توجد طلبات صرف.",
|
||||
"recipient": "المستفيد",
|
||||
"gift": "الصنف",
|
||||
"occasion": "المناسبة",
|
||||
"quantity": "الكمية"
|
||||
},
|
||||
"reports": {
|
||||
"byRoom": "الحجوزات حسب القاعة",
|
||||
"byGift": "الصرف حسب الصنف",
|
||||
"room": "القاعة",
|
||||
"gift": "الصنف",
|
||||
"total": "الإجمالي",
|
||||
"requests": "الطلبات",
|
||||
"issued": "المصروفة",
|
||||
"issuedQty": "الكمية المصروفة",
|
||||
"empty": "لا توجد بيانات."
|
||||
},
|
||||
"audit": {
|
||||
"empty": "لا يوجد نشاط."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,7 +883,9 @@
|
||||
"appName": "Tx OS",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"empty": "(empty)"
|
||||
"empty": "(empty)",
|
||||
"back": "Back",
|
||||
"confirmDelete": "Confirm delete"
|
||||
},
|
||||
"embeddedFrame": {
|
||||
"title": "Embedded app",
|
||||
@@ -1529,5 +1531,111 @@
|
||||
"saved": "Saved",
|
||||
"reset": "Reset to default"
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"title": "Public Relations & Protocol",
|
||||
"notes": "Notes",
|
||||
"statusLabel": "Status",
|
||||
"confirmReject": "Are you sure you want to reject this request?",
|
||||
"confirmDelete": "This action cannot be undone.",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"bookings": "Room Bookings",
|
||||
"external": "External Meetings",
|
||||
"gifts": "Gifts & Shields",
|
||||
"issues": "Issuance Requests",
|
||||
"reports": "Reports",
|
||||
"audit": "Activity Log"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"cancelled": "Cancelled",
|
||||
"issued": "Issued",
|
||||
"scheduled": "Scheduled",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"giftKind": {
|
||||
"gift": "Gift",
|
||||
"shield": "Shield"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"cancel": "Cancel",
|
||||
"approveIssue": "Approve & issue"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "The operation could not be completed."
|
||||
},
|
||||
"dashboard": {
|
||||
"activeRooms": "Active rooms",
|
||||
"todayBookings": "Today's bookings",
|
||||
"pendingBookings": "Bookings awaiting approval",
|
||||
"upcomingExternal": "Upcoming external meetings",
|
||||
"giftStock": "Total gift stock",
|
||||
"pendingIssues": "Pending issuance requests"
|
||||
},
|
||||
"bookings": {
|
||||
"new": "New booking",
|
||||
"edit": "Edit booking",
|
||||
"empty": "No bookings yet.",
|
||||
"requester": "Requester",
|
||||
"roomLabel": "Room",
|
||||
"titleLabel": "Booking title",
|
||||
"startsAt": "From",
|
||||
"endsAt": "To"
|
||||
},
|
||||
"external": {
|
||||
"new": "New meeting",
|
||||
"edit": "Edit meeting",
|
||||
"empty": "No external meetings yet.",
|
||||
"titleLabel": "Meeting title",
|
||||
"party": "Party",
|
||||
"location": "Location",
|
||||
"startsAt": "Date & time"
|
||||
},
|
||||
"gifts": {
|
||||
"new": "New item",
|
||||
"edit": "Edit item",
|
||||
"empty": "No items yet.",
|
||||
"stock": "Stock",
|
||||
"nameAr": "Name (Arabic)",
|
||||
"nameEn": "Name (English)",
|
||||
"kind": "Kind"
|
||||
},
|
||||
"rooms": {
|
||||
"new": "New room",
|
||||
"edit": "Edit room",
|
||||
"title": "Rooms",
|
||||
"inactive": "inactive",
|
||||
"nameAr": "Name (Arabic)",
|
||||
"nameEn": "Name (English)",
|
||||
"capacity": "Capacity",
|
||||
"location": "Location"
|
||||
},
|
||||
"issues": {
|
||||
"new": "Issuance request",
|
||||
"empty": "No issuance requests yet.",
|
||||
"recipient": "Recipient",
|
||||
"gift": "Item",
|
||||
"occasion": "Occasion",
|
||||
"quantity": "Quantity"
|
||||
},
|
||||
"reports": {
|
||||
"byRoom": "Bookings by room",
|
||||
"byGift": "Issuance by item",
|
||||
"room": "Room",
|
||||
"gift": "Item",
|
||||
"total": "Total",
|
||||
"requests": "Requests",
|
||||
"issued": "Issued",
|
||||
"issuedQty": "Issued qty",
|
||||
"empty": "No data."
|
||||
},
|
||||
"audit": {
|
||||
"empty": "No activity yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user