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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user