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:
Riyadh
2026-07-06 09:25:40 +00:00
parent 7ee4c0858a
commit 874d49c439
11 changed files with 3754 additions and 2 deletions
@@ -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();
}
+2
View File
@@ -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
+2
View File
@@ -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
+109 -1
View File
@@ -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": "لا يوجد نشاط."
}
}
}
+109 -1
View File
@@ -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
+1
View File
@@ -27,6 +27,7 @@ export const BUILTIN_APP_SLUGS = [
"executive-meetings",
"calendar",
"documents",
"protocol",
] as const;
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
+1
View File
@@ -14,4 +14,5 @@ export * from "./audit-logs";
export * from "./role-audit";
export * from "./permission-audit";
export * from "./executive-meetings";
export * from "./protocol";
export * from "./push-subscriptions";
+287
View File
@@ -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;
+122
View File
@@ -18,6 +18,7 @@ import {
executiveMeetingsTable,
executiveMeetingAttendeesTable,
executiveMeetingNotificationsTable,
protocolRoomsTable,
} from "@workspace/db";
import { eq, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
@@ -327,6 +328,19 @@ async function main() {
isSystem: false,
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
@@ -345,6 +359,7 @@ async function main() {
"executive-meetings": "/meetings",
calendar: "/calendar",
documents: "/documents",
protocol: "/protocol",
};
for (const a of apps) {
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).
//
// Opt-in only — a vanilla self-hosted install should start with an