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();
|
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 rolesRouter from "./roles";
|
||||||
import auditRouter from "./audit";
|
import auditRouter from "./audit";
|
||||||
import executiveMeetingsRouter from "./executive-meetings";
|
import executiveMeetingsRouter from "./executive-meetings";
|
||||||
|
import protocolRouter from "./protocol";
|
||||||
import pushRouter from "./push";
|
import pushRouter from "./push";
|
||||||
import systemRouter from "./system";
|
import systemRouter from "./system";
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ router.use(groupsRouter);
|
|||||||
router.use(rolesRouter);
|
router.use(rolesRouter);
|
||||||
router.use(auditRouter);
|
router.use(auditRouter);
|
||||||
router.use(executiveMeetingsRouter);
|
router.use(executiveMeetingsRouter);
|
||||||
|
router.use(protocolRouter);
|
||||||
router.use(pushRouter);
|
router.use(pushRouter);
|
||||||
router.use(systemRouter);
|
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 MyOrdersPage from "@/pages/my-orders";
|
||||||
import OrdersIncomingPage from "@/pages/orders-incoming";
|
import OrdersIncomingPage from "@/pages/orders-incoming";
|
||||||
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
|
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
|
||||||
|
import ProtocolPage from "@/pages/protocol";
|
||||||
import EmbeddedAppPage from "@/pages/embedded-app";
|
import EmbeddedAppPage from "@/pages/embedded-app";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -73,6 +74,7 @@ function Router() {
|
|||||||
<Route path="/my-orders" component={MyOrdersPage} />
|
<Route path="/my-orders" component={MyOrdersPage} />
|
||||||
<Route path="/orders/incoming" component={OrdersIncomingPage} />
|
<Route path="/orders/incoming" component={OrdersIncomingPage} />
|
||||||
<Route path="/meetings" component={ExecutiveMeetingsPage} />
|
<Route path="/meetings" component={ExecutiveMeetingsPage} />
|
||||||
|
<Route path="/protocol" component={ProtocolPage} />
|
||||||
{/* Back-compat redirect: the page used to live at
|
{/* Back-compat redirect: the page used to live at
|
||||||
/executive-meetings. Stored PDFs, email links, and bookmarks
|
/executive-meetings. Stored PDFs, email links, and bookmarks
|
||||||
still point there, so catch the old path (and any deep
|
still point there, so catch the old path (and any deep
|
||||||
|
|||||||
@@ -971,7 +971,9 @@
|
|||||||
"appName": "نظام Tx",
|
"appName": "نظام Tx",
|
||||||
"previous": "السابق",
|
"previous": "السابق",
|
||||||
"next": "التالي",
|
"next": "التالي",
|
||||||
"empty": "(فارغ)"
|
"empty": "(فارغ)",
|
||||||
|
"back": "رجوع",
|
||||||
|
"confirmDelete": "تأكيد الحذف"
|
||||||
},
|
},
|
||||||
"embeddedFrame": {
|
"embeddedFrame": {
|
||||||
"title": "تطبيق مدمج",
|
"title": "تطبيق مدمج",
|
||||||
@@ -1656,5 +1658,111 @@
|
|||||||
"saved": "تم الحفظ",
|
"saved": "تم الحفظ",
|
||||||
"reset": "إعادة الافتراضي"
|
"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",
|
"appName": "Tx OS",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"empty": "(empty)"
|
"empty": "(empty)",
|
||||||
|
"back": "Back",
|
||||||
|
"confirmDelete": "Confirm delete"
|
||||||
},
|
},
|
||||||
"embeddedFrame": {
|
"embeddedFrame": {
|
||||||
"title": "Embedded app",
|
"title": "Embedded app",
|
||||||
@@ -1529,5 +1531,111 @@
|
|||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"reset": "Reset to default"
|
"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
@@ -27,6 +27,7 @@ export const BUILTIN_APP_SLUGS = [
|
|||||||
"executive-meetings",
|
"executive-meetings",
|
||||||
"calendar",
|
"calendar",
|
||||||
"documents",
|
"documents",
|
||||||
|
"protocol",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
|
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ export * from "./audit-logs";
|
|||||||
export * from "./role-audit";
|
export * from "./role-audit";
|
||||||
export * from "./permission-audit";
|
export * from "./permission-audit";
|
||||||
export * from "./executive-meetings";
|
export * from "./executive-meetings";
|
||||||
|
export * from "./protocol";
|
||||||
export * from "./push-subscriptions";
|
export * from "./push-subscriptions";
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
serial,
|
||||||
|
integer,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { usersTable } from "./users";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public Relations & Protocol module ("العلاقات العامة والمراسم").
|
||||||
|
//
|
||||||
|
// A completely standalone module. Every table is prefixed `protocol_` and has
|
||||||
|
// no foreign keys into the Executive Meetings tables, so the two modules stay
|
||||||
|
// fully independent. Only `usersTable` (the shared account table) is
|
||||||
|
// referenced, and always with `on delete set null` so removing a user never
|
||||||
|
// cascades into protocol history.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Status values for room bookings and gift issuances. The overlap /
|
||||||
|
// conflict-prevention rule only considers "pending" and "approved" rows as
|
||||||
|
// occupying a room; "rejected" and "cancelled" free the slot again.
|
||||||
|
export const PROTOCOL_BOOKING_STATUSES = [
|
||||||
|
"pending",
|
||||||
|
"approved",
|
||||||
|
"rejected",
|
||||||
|
"cancelled",
|
||||||
|
] as const;
|
||||||
|
export type ProtocolBookingStatus =
|
||||||
|
(typeof PROTOCOL_BOOKING_STATUSES)[number];
|
||||||
|
|
||||||
|
export const PROTOCOL_ISSUE_STATUSES = [
|
||||||
|
"pending",
|
||||||
|
"approved",
|
||||||
|
"rejected",
|
||||||
|
"issued",
|
||||||
|
] as const;
|
||||||
|
export type ProtocolIssueStatus = (typeof PROTOCOL_ISSUE_STATUSES)[number];
|
||||||
|
|
||||||
|
// Gift catalogue kinds: a regular gift or a commemorative shield (درع).
|
||||||
|
export const PROTOCOL_GIFT_KINDS = ["gift", "shield"] as const;
|
||||||
|
export type ProtocolGiftKind = (typeof PROTOCOL_GIFT_KINDS)[number];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rooms
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const protocolRoomsTable = pgTable(
|
||||||
|
"protocol_rooms",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
nameAr: varchar("name_ar", { length: 200 }).notNull(),
|
||||||
|
nameEn: varchar("name_en", { length: 200 }).notNull().default(""),
|
||||||
|
capacity: integer("capacity"),
|
||||||
|
location: varchar("location", { length: 300 }),
|
||||||
|
isActive: boolean("is_active").notNull().default(true),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
activeIdx: index("protocol_rooms_active_idx").on(t.isActive),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Room bookings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const protocolRoomBookingsTable = pgTable(
|
||||||
|
"protocol_room_bookings",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
roomId: integer("room_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => protocolRoomsTable.id, { onDelete: "cascade" }),
|
||||||
|
title: varchar("title", { length: 500 }).notNull(),
|
||||||
|
requesterName: varchar("requester_name", { length: 300 }),
|
||||||
|
// Full timestamps so bookings can span any part of a day. The overlap
|
||||||
|
// rule compares [startsAt, endsAt) intervals per room.
|
||||||
|
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
||||||
|
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||||
|
rejectionReason: text("rejection_reason"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
roomIdx: index("protocol_bookings_room_idx").on(t.roomId),
|
||||||
|
startIdx: index("protocol_bookings_start_idx").on(t.startsAt),
|
||||||
|
statusIdx: index("protocol_bookings_status_idx").on(t.status),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// External protocol meetings (اجتماعات ومراسم خارجية) — independent from the
|
||||||
|
// Executive Meetings module's is_external flag.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const protocolExternalMeetingsTable = pgTable(
|
||||||
|
"protocol_external_meetings",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
title: varchar("title", { length: 500 }).notNull(),
|
||||||
|
// The visiting party / external delegation.
|
||||||
|
partyName: varchar("party_name", { length: 300 }),
|
||||||
|
location: varchar("location", { length: 300 }),
|
||||||
|
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
||||||
|
endsAt: timestamp("ends_at", { withTimezone: true }),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("scheduled"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
startIdx: index("protocol_ext_meetings_start_idx").on(t.startsAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gifts & shields catalogue
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const protocolGiftsTable = pgTable(
|
||||||
|
"protocol_gifts",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
nameAr: varchar("name_ar", { length: 300 }).notNull(),
|
||||||
|
nameEn: varchar("name_en", { length: 300 }).notNull().default(""),
|
||||||
|
kind: varchar("kind", { length: 20 }).notNull().default("gift"),
|
||||||
|
descriptionAr: text("description_ar"),
|
||||||
|
descriptionEn: text("description_en"),
|
||||||
|
imageUrl: varchar("image_url", { length: 500 }),
|
||||||
|
// Running stock count. Issuing an approved gift decrements this.
|
||||||
|
quantityInStock: integer("quantity_in_stock").notNull().default(0),
|
||||||
|
isActive: boolean("is_active").notNull().default(true),
|
||||||
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
kindIdx: index("protocol_gifts_kind_idx").on(t.kind),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gift issuances (صرف الهدايا/الدروع)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const protocolGiftIssuesTable = pgTable(
|
||||||
|
"protocol_gift_issues",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
giftId: integer("gift_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => protocolGiftsTable.id, { onDelete: "restrict" }),
|
||||||
|
recipientName: varchar("recipient_name", { length: 300 }).notNull(),
|
||||||
|
occasion: varchar("occasion", { length: 300 }),
|
||||||
|
quantity: integer("quantity").notNull().default(1),
|
||||||
|
issuedAt: timestamp("issued_at", { withTimezone: true }),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||||
|
rejectionReason: text("rejection_reason"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
giftIdx: index("protocol_gift_issues_gift_idx").on(t.giftId),
|
||||||
|
statusIdx: index("protocol_gift_issues_status_idx").on(t.status),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audit log (scoped to the protocol module only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const protocolAuditLogsTable = pgTable(
|
||||||
|
"protocol_audit_logs",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
action: varchar("action", { length: 100 }).notNull(),
|
||||||
|
entityType: varchar("entity_type", { length: 50 }).notNull(),
|
||||||
|
entityId: integer("entity_id"),
|
||||||
|
oldValue: jsonb("old_value"),
|
||||||
|
newValue: jsonb("new_value"),
|
||||||
|
performedBy: integer("performed_by").references(() => usersTable.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
performedAt: timestamp("performed_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
entityIdx: index("protocol_audit_entity_idx").on(t.entityType, t.entityId),
|
||||||
|
performedAtIdx: index("protocol_audit_performed_at_idx").on(t.performedAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Insert schemas / row types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const insertProtocolRoomSchema = createInsertSchema(
|
||||||
|
protocolRoomsTable,
|
||||||
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||||
|
export type InsertProtocolRoom = z.infer<typeof insertProtocolRoomSchema>;
|
||||||
|
export type ProtocolRoom = typeof protocolRoomsTable.$inferSelect;
|
||||||
|
|
||||||
|
export const insertProtocolRoomBookingSchema = createInsertSchema(
|
||||||
|
protocolRoomBookingsTable,
|
||||||
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||||
|
export type InsertProtocolRoomBooking = z.infer<
|
||||||
|
typeof insertProtocolRoomBookingSchema
|
||||||
|
>;
|
||||||
|
export type ProtocolRoomBooking = typeof protocolRoomBookingsTable.$inferSelect;
|
||||||
|
|
||||||
|
export const insertProtocolExternalMeetingSchema = createInsertSchema(
|
||||||
|
protocolExternalMeetingsTable,
|
||||||
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||||
|
export type InsertProtocolExternalMeeting = z.infer<
|
||||||
|
typeof insertProtocolExternalMeetingSchema
|
||||||
|
>;
|
||||||
|
export type ProtocolExternalMeeting =
|
||||||
|
typeof protocolExternalMeetingsTable.$inferSelect;
|
||||||
|
|
||||||
|
export const insertProtocolGiftSchema = createInsertSchema(
|
||||||
|
protocolGiftsTable,
|
||||||
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||||
|
export type InsertProtocolGift = z.infer<typeof insertProtocolGiftSchema>;
|
||||||
|
export type ProtocolGift = typeof protocolGiftsTable.$inferSelect;
|
||||||
|
|
||||||
|
export const insertProtocolGiftIssueSchema = createInsertSchema(
|
||||||
|
protocolGiftIssuesTable,
|
||||||
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||||
|
export type InsertProtocolGiftIssue = z.infer<
|
||||||
|
typeof insertProtocolGiftIssueSchema
|
||||||
|
>;
|
||||||
|
export type ProtocolGiftIssue = typeof protocolGiftIssuesTable.$inferSelect;
|
||||||
|
|
||||||
|
export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect;
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
executiveMeetingsTable,
|
executiveMeetingsTable,
|
||||||
executiveMeetingAttendeesTable,
|
executiveMeetingAttendeesTable,
|
||||||
executiveMeetingNotificationsTable,
|
executiveMeetingNotificationsTable,
|
||||||
|
protocolRoomsTable,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
@@ -327,6 +328,19 @@ async function main() {
|
|||||||
isSystem: false,
|
isSystem: false,
|
||||||
sortOrder: 7,
|
sortOrder: 7,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: "protocol",
|
||||||
|
nameAr: "العلاقات العامة والمراسم",
|
||||||
|
nameEn: "Public Relations & Protocol",
|
||||||
|
descriptionAr: "حجز القاعات والاجتماعات الخارجية والهدايا والدروع",
|
||||||
|
descriptionEn: "Room bookings, external meetings, gifts and shields",
|
||||||
|
iconName: "Handshake",
|
||||||
|
route: "/protocol",
|
||||||
|
color: "#0ea5e9",
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
sortOrder: 9,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Drift guard: every built-in slug (those with hardcoded routes in the
|
// Drift guard: every built-in slug (those with hardcoded routes in the
|
||||||
@@ -345,6 +359,7 @@ async function main() {
|
|||||||
"executive-meetings": "/meetings",
|
"executive-meetings": "/meetings",
|
||||||
calendar: "/calendar",
|
calendar: "/calendar",
|
||||||
documents: "/documents",
|
documents: "/documents",
|
||||||
|
protocol: "/protocol",
|
||||||
};
|
};
|
||||||
for (const a of apps) {
|
for (const a of apps) {
|
||||||
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {
|
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {
|
||||||
@@ -763,6 +778,113 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public Relations & Protocol: roles.
|
||||||
|
const protocolRoles = [
|
||||||
|
{
|
||||||
|
name: "protocol_super_admin",
|
||||||
|
descriptionAr: "المدير العام للعلاقات العامة والمراسم",
|
||||||
|
descriptionEn: "Protocol Super Admin",
|
||||||
|
isSystem: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol_pr_manager",
|
||||||
|
descriptionAr: "مدير العلاقات العامة",
|
||||||
|
descriptionEn: "PR Manager",
|
||||||
|
isSystem: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol_officer",
|
||||||
|
descriptionAr: "موظف المراسم",
|
||||||
|
descriptionEn: "Protocol Officer",
|
||||||
|
isSystem: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol_requester",
|
||||||
|
descriptionAr: "مقدم الطلب",
|
||||||
|
descriptionEn: "Requester",
|
||||||
|
isSystem: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol_viewer",
|
||||||
|
descriptionAr: "مشاهد العلاقات العامة والمراسم",
|
||||||
|
descriptionEn: "Protocol Viewer",
|
||||||
|
isSystem: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await db.insert(rolesTable).values(protocolRoles).onConflictDoNothing();
|
||||||
|
console.log("Protocol roles created");
|
||||||
|
|
||||||
|
// Gate the Protocol home-screen icon to admin + the 5 protocol roles.
|
||||||
|
await db
|
||||||
|
.insert(permissionsTable)
|
||||||
|
.values({
|
||||||
|
name: "protocol.access",
|
||||||
|
descriptionAr: "الوصول إلى وحدة العلاقات العامة والمراسم",
|
||||||
|
descriptionEn: "Access the Public Relations & Protocol module",
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
const [protocolAccessPerm] = await db
|
||||||
|
.select()
|
||||||
|
.from(permissionsTable)
|
||||||
|
.where(eq(permissionsTable.name, "protocol.access"));
|
||||||
|
|
||||||
|
if (protocolAccessPerm) {
|
||||||
|
const protocolRoleNames = [
|
||||||
|
"admin",
|
||||||
|
"protocol_super_admin",
|
||||||
|
"protocol_pr_manager",
|
||||||
|
"protocol_officer",
|
||||||
|
"protocol_requester",
|
||||||
|
"protocol_viewer",
|
||||||
|
];
|
||||||
|
const allRolesForProtocol = await db.select().from(rolesTable);
|
||||||
|
const protocolRoleRows = allRolesForProtocol.filter((r) =>
|
||||||
|
protocolRoleNames.includes(r.name),
|
||||||
|
);
|
||||||
|
if (protocolRoleRows.length > 0) {
|
||||||
|
await db
|
||||||
|
.insert(rolePermissionsTable)
|
||||||
|
.values(
|
||||||
|
protocolRoleRows.map((r) => ({
|
||||||
|
roleId: r.id,
|
||||||
|
permissionId: protocolAccessPerm.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [protocolApp] = await db
|
||||||
|
.select()
|
||||||
|
.from(appsTable)
|
||||||
|
.where(eq(appsTable.slug, "protocol"));
|
||||||
|
if (protocolApp) {
|
||||||
|
await db
|
||||||
|
.insert(appPermissionsTable)
|
||||||
|
.values({ appId: protocolApp.id, permissionId: protocolAccessPerm.id })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
console.log(
|
||||||
|
"App permissions set: protocol app restricted to protocol.access permission",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public Relations & Protocol: three default meeting rooms. Only seeded
|
||||||
|
// when the table is empty so admins can rename/remove them freely without
|
||||||
|
// re-seeds bringing them back.
|
||||||
|
const existingRooms = await db
|
||||||
|
.select({ id: protocolRoomsTable.id })
|
||||||
|
.from(protocolRoomsTable)
|
||||||
|
.limit(1);
|
||||||
|
if (existingRooms.length === 0) {
|
||||||
|
await db.insert(protocolRoomsTable).values([
|
||||||
|
{ nameAr: "القاعة الأولى", nameEn: "Room 1", sortOrder: 1 },
|
||||||
|
{ nameAr: "القاعة الثانية", nameEn: "Room 2", sortOrder: 2 },
|
||||||
|
{ nameAr: "القاعة الثالثة", nameEn: "Room 3", sortOrder: 3 },
|
||||||
|
]);
|
||||||
|
console.log("Protocol default rooms created");
|
||||||
|
}
|
||||||
|
|
||||||
// Executive meetings: sample data for today (idempotent per-date).
|
// Executive meetings: sample data for today (idempotent per-date).
|
||||||
//
|
//
|
||||||
// Opt-in only — a vanilla self-hosted install should start with an
|
// Opt-in only — a vanilla self-hosted install should start with an
|
||||||
|
|||||||
Reference in New Issue
Block a user