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:
Replit Agent
2026-07-06 09:25:40 +00:00
parent 62470c580d
commit 2322b77b8d
11 changed files with 3754 additions and 2 deletions
+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