Compare commits

12 Commits

Author SHA1 Message Date
Replit Agent 16e30e1bb1 Add project bundle for offline transfer and backup
Add a new asset metadata entry for the TX-latest.tar.gz project bundle.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ba675b94-b057-4118-8161-797925b5bbb8
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/bJodRQl
Replit-Helium-Checkpoint-Created: true
2026-07-06 14:13:26 +00:00
Replit Agent 17950daa5e feat(protocol): shareable public booking link bar in Room Bookings
Task #655: staff need a one-click way to copy and share the public
(no-login) room-booking link with guests.

- Add a link bar at the top of the "حجوزات القاعات" (Room Bookings)
  section showing the absolute public URL, with a "نسخ الرابط" copy
  button that gives toast + a temporary "تم النسخ" confirmation state.
- URL is built from window.location.origin + BASE_URL (trailing slash
  stripped) + "/protocol/request", so it works on root and subpath
  deploys and when pasted into a browser/WhatsApp.
- Clipboard uses navigator.clipboard with a textarea+execCommand
  fallback; fallback now checks execCommand's return and reports failure
  (destructive toast) instead of falsely showing success. Textarea is
  removed in a finally block. Copied-state timer stored in a ref and
  cleared on repeat clicks to avoid a reset race.
- URL text forced dir="ltr" inside the RTL layout; new i18n keys
  (shareLinkTitle, copyLink, linkCopied, linkCopyFailed) added under
  protocol.bookings in both ar.json and en.json.

Verified: tsc typecheck passes; /protocol serves 200; module transforms
cleanly via Vite; architect review issues (fallback false-success,
timer race) fixed.
2026-07-06 12:20:17 +00:00
Replit Agent 323472eec0 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: f62b3d4a-2b74-4d08-886a-87517e159301
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/OsTuUKE
Replit-Helium-Checkpoint-Created: true
2026-07-06 12:16:35 +00:00
Replit Agent 457eff44c4 Add professional Arabic date and time picker to booking form
Introduce a custom DateTimePicker component for improved date and time selection in the protocol request form, replacing native inputs with an Arabic-localized, RTL-compatible interface.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 8b01db58-6cde-464b-bd7d-46bd031e467f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/OsTuUKE
Replit-Helium-Checkpoint-Created: true
2026-07-06 11:40:36 +00:00
Replit Agent 2a194de12c Add attendee count field to booking requests and display it
Updates API schemas and database to include attendee count for bookings, modifies the public request form to accept this new optional field, and displays the attendee count on the booking details.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 9c655ed7-866c-454d-b646-e458df854c65
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/OsTuUKE
Replit-Helium-Checkpoint-Created: true
2026-07-06 11:33:58 +00:00
Replit Agent 62c38a508f Add public interface for submitting room booking requests
Introduces a new public-facing API endpoint and UI for submitting room booking requests without authentication, including rate limiting and input validation.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 5352d582-4c15-426c-84ce-4ba3fa0d5cd8
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/OsTuUKE
Replit-Helium-Checkpoint-Created: true
2026-07-06 11:18:14 +00:00
Replit Agent 6c5ae9d437 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 34b757e2-71fa-4d1c-8840-e8c61b98d0f2
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/9SfnFMU
Replit-Helium-Checkpoint-Created: true
2026-07-06 11:05:30 +00:00
Replit Agent 5d76d25eb7 Move navigation to a vertical sidebar on the right for better layout
Restructures the page layout to move the navigation tabs from a horizontal bar to a vertical sidebar on the right, enhancing RTL support.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ec325dd2-ed6f-4785-8de9-93ec3207af08
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/9SfnFMU
Replit-Helium-Checkpoint-Created: true
2026-07-06 10:13:41 +00:00
Replit Agent 5fe5194349 Task #649: Public Relations & Protocol module (bookings, external meetings, gifts)
Separate additive module, fully independent from executive-meetings. All tables
prefixed protocol_, routes under /protocol, Arabic-first RTL UI.

This session: addressed code-review rejection by switching protocol
authorization from hardcoded role-name checks to scoped permission checks.
- auth.ts: requireProtocolAccess now guards on the "protocol.access" permission
  (same permission that gates the home-screen tile via app_permissions), so
  backend read access aligns with tile visibility.
- protocol.ts: replaced role-name helpers with makeRequirePerm + PROTOCOL_PERMS
  map (protocol.access / request / mutate / approve / rooms.manage / audit.read).
  Booking auto-approve and /protocol/me capabilities now derive from permissions.
- seed.ts: seeds all 6 scoped permissions and maps them to admin + the 5 protocol
  roles (access:6, request:5, mutate:4, approve:3, audit:3, rooms:2 role grants).

Verified: typecheck clean (tx-os, api-server protocol/auth, scripts), seed applied,
DB mappings confirmed, /api/protocol/me returns 401 unauth. Architect review PASS.

Deviations: none from spec. Frontend already consumed capability flags from /me,
so no frontend changes were needed for the permission switch.
2026-07-06 09:54:30 +00:00
Replit Agent 5e460920c7 Task #649: complete Public Relations & Protocol module (fix 8 review gaps)
Addresses the 8 spec gaps that blocked prior completion, all additive and
scoped to the protocol_* module / /protocol routes. executive-meetings untouched.

Backend (api-server/routes/protocol.ts, lib/db schema, seed):
- External meetings create as "pending" (status dropped from create schema);
  added approve + reject endpoints with state-conditional WHERE status='pending'
  and 409 on contention; patch status enum restricted to completed/cancelled.
- Added approvedBy/approvedAt/rejectionReason columns + status index; default
  status now "pending".
- Booking reject and gift-issue reject made state-conditional (409 on 0 rows).
- Dashboard: roomsAvailableNow (NOT EXISTS), giftsIssuedThisMonth/Qty;
  upcomingExternal filtered to future pending|approved.
- Reports: added externalMeetings summary counts by status.
- Seed: room names corrected (AR + EN).

Frontend (tx-os/pages/protocol.tsx, App.tsx, locales):
- URL-synced tabs under /protocol/:tab (slug<->tab maps).
- Dedicated Rooms tab (moved out of Gifts); RoomDialog isActive toggle.
- External approve/reject buttons + rejectionReason display; ExternalDialog
  create omits status, edit limited to completed/cancelled.
- Dashboard cards clickable to navigate; reports external section.
- Bookings list/calendar (grouped-by-day) view toggle.
- New i18n keys in ar.json + en.json.

Verification: tx-os + protocol.ts typecheck clean; drizzle push + re-seed done;
both workflows restarted; /api/protocol/me returns 401 unauth; architect
re-review approved. Pre-existing typecheck errors in executive-meetings.ts and
push.ts are unrelated and were not touched.

Note: had to rebuild lib/db declarations (tsc --build --force) so api-server
project references picked up the new schema columns.
2026-07-06 09:46:12 +00:00
Replit Agent 2322b77b8d 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.
2026-07-06 09:25:40 +00:00
Replit Agent 62470c580d Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: e4960c19-0ee6-410c-95a5-24eefd66c0b7
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/sVsVYJx
Replit-Helium-Checkpoint-Created: true
2026-07-06 08:59:30 +00:00
14 changed files with 5204 additions and 4 deletions
+6
View File
@@ -119,3 +119,9 @@ id = "FXgAx0l684F40vfGD6lqD"
uri = "file://attached_assets/generated_images/platform_architecture_ar.png"
type = "image"
title = "بنية المنصة — تخطيط احترافي"
[[outputs]]
id = "8gXcnoA-hNBH1c_T0ki__"
uri = "file://dist/TX-latest.tar.gz"
type = "unspecified"
title = "TX-latest — كامل المشروع مع التاريخ (git bundle)"
@@ -253,3 +253,36 @@ export async function requireExecutiveAccess(
next();
}
/**
* Gate any read access to the Public Relations & Protocol module. Guarded by
* the scoped `protocol.access` permission — the same permission that gates the
* home-screen tile (via app_permissions) — so backend usability aligns exactly
* with the visible permission grant. Finer-grained request / mutate / approve /
* rooms / audit capabilities are layered on top via scoped permissions 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 ok = await userHasPermission(req.session.userId, "protocol.access");
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
+13
View File
@@ -27,6 +27,8 @@ 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 ProtocolRequestPage from "@/pages/protocol-request";
import EmbeddedAppPage from "@/pages/embedded-app";
const queryClient = new QueryClient({
@@ -73,6 +75,8 @@ function Router() {
<Route path="/my-orders" component={MyOrdersPage} />
<Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/meetings" component={ExecutiveMeetingsPage} />
<Route path="/protocol" component={ProtocolPage} />
<Route path="/protocol/:tab" 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
@@ -94,7 +98,16 @@ function App() {
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
{/* The public room booking request form must render for logged-OUT
guests, so it lives OUTSIDE <Router> (and thus outside
AuthProvider, which redirects unauthenticated users to /login).
The catch-all falls through to the authenticated app. */}
<Switch>
<Route path="/protocol/request" component={ProtocolRequestPage} />
<Route>
<Router />
</Route>
</Switch>
</WouterRouter>
<Toaster />
</TooltipProvider>
@@ -0,0 +1,224 @@
import * as React from "react";
import { CalendarClock } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const AR_LATN = "ar-u-nu-latn";
function pad(n: number): string {
return String(n).padStart(2, "0");
}
// Value contract: local wall-clock string "YYYY-MM-DDTHH:mm" (same shape a
// native datetime-local input produces) or "" when nothing is selected.
function parseValue(value: string): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
function toValue(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`;
}
function to24(hour12: number, period: "am" | "pm"): number {
if (period === "am") return hour12 === 12 ? 0 : hour12;
return hour12 === 12 ? 12 : hour12 + 12;
}
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1);
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
function TimeColumn({
values,
active,
onSelect,
format,
ariaLabel,
}: {
values: number[];
active: number;
onSelect: (v: number) => void;
format: (v: number) => string;
ariaLabel: string;
}) {
const activeRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
activeRef.current?.scrollIntoView({ block: "center" });
}, [active]);
return (
<div
role="listbox"
aria-label={ariaLabel}
className="h-40 w-14 overflow-y-auto rounded-md border p-1"
>
<div className="flex flex-col gap-1">
{values.map((v) => {
const isActive = v === active;
return (
<Button
key={v}
ref={isActive ? activeRef : undefined}
type="button"
size="sm"
variant={isActive ? "default" : "ghost"}
className="h-8 w-full shrink-0"
onClick={() => onSelect(v)}
>
{format(v)}
</Button>
);
})}
</div>
</div>
);
}
export function DateTimePicker({
id,
value,
onChange,
placeholder = "اختر التاريخ والوقت",
disabled,
minDate,
}: {
id?: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
minDate?: Date;
}) {
const [open, setOpen] = React.useState(false);
const selected = parseValue(value);
const hour12 = selected ? selected.getHours() % 12 || 12 : 9;
const minute = selected ? selected.getMinutes() : 0;
const period: "am" | "pm" = selected
? selected.getHours() < 12
? "am"
: "pm"
: "am";
const baseDate = (): Date => {
if (selected) return new Date(selected);
const d = minDate ? new Date(minDate) : new Date();
d.setHours(9, 0, 0, 0);
return d;
};
const commit = (next: Date) => onChange(toValue(next));
const handleDay = (day: Date | undefined) => {
if (!day) return;
const base = baseDate();
const next = new Date(day);
next.setHours(base.getHours(), base.getMinutes(), 0, 0);
commit(next);
};
const handleHour = (h: number) => {
const next = baseDate();
next.setHours(to24(h, period), next.getMinutes(), 0, 0);
commit(next);
};
const handleMinute = (m: number) => {
const next = baseDate();
next.setMinutes(m, 0, 0);
commit(next);
};
const handlePeriod = (p: "am" | "pm") => {
const next = baseDate();
next.setHours(to24(hour12, p), next.getMinutes(), 0, 0);
commit(next);
};
const label = selected
? new Intl.DateTimeFormat(AR_LATN, {
dateStyle: "medium",
timeStyle: "short",
}).format(selected)
: placeholder;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
!selected && "text-muted-foreground",
)}
>
<span className="truncate">{label}</span>
<CalendarClock className="size-4 shrink-0 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={handleDay}
defaultMonth={selected ?? minDate ?? new Date()}
disabled={minDate ? { before: minDate } : undefined}
autoFocus
/>
<div className="border-t p-3">
<div className="mb-2 text-center text-xs font-medium text-muted-foreground">
الوقت
</div>
<div className="flex items-stretch justify-center gap-2">
<TimeColumn
values={HOURS_12}
active={hour12}
onSelect={handleHour}
format={pad}
ariaLabel="الساعة"
/>
<div className="flex items-center text-muted-foreground">:</div>
<TimeColumn
values={MINUTES}
active={minute}
onSelect={handleMinute}
format={pad}
ariaLabel="الدقيقة"
/>
<div className="flex flex-col justify-center gap-1">
{(["am", "pm"] as const).map((p) => (
<Button
key={p}
type="button"
size="sm"
variant={period === p ? "default" : "outline"}
className="h-8 w-12"
onClick={() => handlePeriod(p)}
>
{p === "am" ? "ص" : "م"}
</Button>
))}
</div>
</div>
</div>
<div className="flex justify-end border-t p-2">
<Button type="button" size="sm" onClick={() => setOpen(false)}>
تم
</Button>
</div>
</PopoverContent>
</Popover>
);
}
+126 -1
View File
@@ -971,7 +971,9 @@
"appName": "نظام Tx",
"previous": "السابق",
"next": "التالي",
"empty": "(فارغ)"
"empty": "(فارغ)",
"back": "رجوع",
"confirmDelete": "تأكيد الحذف"
},
"embeddedFrame": {
"title": "تطبيق مدمج",
@@ -1656,5 +1658,128 @@
"saved": "تم الحفظ",
"reset": "إعادة الافتراضي"
}
},
"protocol": {
"title": "العلاقات العامة والمراسم",
"notes": "ملاحظات",
"statusLabel": "الحالة",
"confirmReject": "هل أنت متأكد من رفض هذا الطلب؟",
"confirmDelete": "لا يمكن التراجع عن هذا الإجراء.",
"tabs": {
"dashboard": "لوحة المعلومات",
"bookings": "حجوزات القاعات",
"external": "اللقاءات الخارجية",
"gifts": "الهدايا والدروع",
"issues": "طلبات الصرف",
"reports": "التقارير",
"audit": "سجل النشاط",
"rooms": "القاعات"
},
"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": "طلبات صرف معلّقة",
"roomsAvailableNow": "القاعات المتاحة الآن",
"giftsIssuedThisMonth": "الدروع المصروفة هذا الشهر"
},
"bookings": {
"new": "حجز جديد",
"edit": "تعديل الحجز",
"empty": "لا توجد حجوزات.",
"requester": "مقدّم الطلب",
"org": "الجهة",
"phone": "الهاتف",
"attendeeCount": "عدد الحضور",
"publicRequest": "طلب عام",
"roomLabel": "القاعة",
"titleLabel": "عنوان الحجز",
"startsAt": "من",
"endsAt": "إلى",
"viewList": "قائمة",
"viewCalendar": "تقويم",
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
"copyLink": "نسخ الرابط",
"linkCopied": "تم نسخ الرابط",
"linkCopyFailed": "تعذّر نسخ الرابط"
},
"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": "الموقع",
"active": "مفعّلة",
"isActive": "مفعّلة",
"empty": "لا توجد قاعات."
},
"issues": {
"new": "طلب صرف",
"empty": "لا توجد طلبات صرف.",
"recipient": "المستفيد",
"gift": "الصنف",
"occasion": "المناسبة",
"quantity": "الكمية"
},
"reports": {
"byRoom": "الحجوزات حسب القاعة",
"byGift": "الصرف حسب الصنف",
"room": "القاعة",
"gift": "الصنف",
"total": "الإجمالي",
"requests": "الطلبات",
"issued": "المصروفة",
"issuedQty": "الكمية المصروفة",
"empty": "لا توجد بيانات.",
"byExternal": "اللقاءات الخارجية حسب الحالة"
},
"audit": {
"empty": "لا يوجد نشاط."
}
}
}
+126 -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,128 @@
"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",
"rooms": "Rooms"
},
"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",
"roomsAvailableNow": "Rooms available now",
"giftsIssuedThisMonth": "Gifts issued this month"
},
"bookings": {
"new": "New booking",
"edit": "Edit booking",
"empty": "No bookings yet.",
"requester": "Requester",
"org": "Organization",
"phone": "Phone",
"attendeeCount": "Attendees",
"publicRequest": "Public request",
"roomLabel": "Room",
"titleLabel": "Booking title",
"startsAt": "From",
"endsAt": "To",
"viewList": "List",
"viewCalendar": "Calendar",
"shareLinkTitle": "Public booking link (share with guests)",
"copyLink": "Copy link",
"linkCopied": "Link copied",
"linkCopyFailed": "Could not copy the link"
},
"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",
"active": "Active",
"isActive": "Active",
"empty": "No rooms."
},
"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.",
"byExternal": "External meetings by status"
},
"audit": {
"empty": "No activity yet."
}
}
}
@@ -0,0 +1,364 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CalendarClock, CheckCircle2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DateTimePicker } from "@/components/date-time-picker";
const API = `${import.meta.env.BASE_URL}api`;
type PublicRoom = { id: number; nameAr: string; nameEn: string };
type FormState = {
requesterName: string;
title: string;
requesterPhone: string;
requesterOrg: string;
attendeeCount: string;
roomId: string;
startsAt: string;
endsAt: string;
notes: string;
website: string; // honeypot
};
const EMPTY: FormState = {
requesterName: "",
title: "",
requesterPhone: "",
requesterOrg: "",
attendeeCount: "",
roomId: "",
startsAt: "",
endsAt: "",
notes: "",
website: "",
};
function localToIso(v: string): string {
return new Date(v).toISOString();
}
export default function ProtocolRequestPage() {
// This page is public (no auth). Force RTL Arabic regardless of any
// stored language preference so a guest always sees the intended form.
useEffect(() => {
const prevDir = document.documentElement.dir;
const prevLang = document.documentElement.lang;
document.documentElement.dir = "rtl";
document.documentElement.lang = "ar";
return () => {
// Restore the app's direction/language so navigating away from this
// public page doesn't leave the rest of the app forced into RTL Arabic.
document.documentElement.dir = prevDir;
document.documentElement.lang = prevLang;
};
}, []);
const rooms = useQuery<PublicRoom[]>({
queryKey: ["protocol-public", "rooms"],
queryFn: async () => {
const r = await fetch(`${API}/protocol/public/rooms`);
if (!r.ok) throw new Error("rooms");
return r.json();
},
});
const [form, setForm] = useState<FormState>(EMPTY);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const canSubmit = useMemo(() => {
return (
form.requesterName.trim().length > 0 &&
form.title.trim().length > 0 &&
form.requesterPhone.trim().length >= 3 &&
form.roomId !== "" &&
form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) < new Date(form.endsAt)
);
}, [form]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!canSubmit || submitting) return;
setSubmitting(true);
try {
const res = await fetch(`${API}/protocol/public/booking-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: Number(form.roomId),
title: form.title.trim(),
requesterName: form.requesterName.trim(),
requesterPhone: form.requesterPhone.trim(),
requesterOrg: form.requesterOrg.trim() || null,
attendeeCount:
form.attendeeCount.trim() === ""
? null
: Number(form.attendeeCount),
startsAt: localToIso(form.startsAt),
endsAt: localToIso(form.endsAt),
notes: form.notes.trim() || null,
website: form.website,
}),
});
if (res.ok) {
setDone(true);
return;
}
let msg = "تعذّر إرسال الطلب، يرجى المحاولة مرة أخرى.";
try {
const data = await res.json();
if (data?.error && typeof data.error === "string") msg = data.error;
} catch {
// keep default message
}
if (res.status === 429) {
msg = "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.";
}
setError(msg);
} catch {
setError("تعذّر الاتصال بالخادم، تحقق من الاتصال وحاول مجدداً.");
} finally {
setSubmitting(false);
}
};
if (done) {
return (
<div
dir="rtl"
className="min-h-screen bg-slate-50 flex items-center justify-center p-4"
>
<div className="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-sm p-8 text-center">
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-slate-800 mb-2">
تم استلام طلبك
</h1>
<p className="text-slate-500 leading-relaxed">
سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف
المُدخل لتأكيد الموعد.
</p>
<Button
className="mt-6"
variant="outline"
onClick={() => {
setForm(EMPTY);
setDone(false);
}}
>
إرسال طلب آخر
</Button>
</div>
</div>
);
}
return (
<div dir="rtl" className="min-h-screen bg-slate-50 py-8 px-4">
<div className="w-full max-w-xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<div className="w-11 h-11 rounded-xl bg-sky-500/10 text-sky-600 flex items-center justify-center">
<CalendarClock className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-800">طلب حجز قاعة</h1>
<p className="text-sm text-slate-500">
يرجى تعبئة البيانات وسيتواصل معك فريق المراسم للتأكيد.
</p>
</div>
</div>
<form
onSubmit={onSubmit}
className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 space-y-5"
>
{/* Honeypot: hidden from real users, catches bots. */}
<div className="hidden" aria-hidden="true">
<label>
Website
<input
type="text"
tabIndex={-1}
autoComplete="off"
value={form.website}
onChange={(e) => set("website", e.target.value)}
/>
</label>
</div>
<div className="space-y-2">
<Label htmlFor="requesterName">
الاسم الكامل <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterName"
value={form.requesterName}
onChange={(e) => set("requesterName", e.target.value)}
placeholder="الاسم الثلاثي"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">
عنوان الاجتماع <span className="text-rose-500">*</span>
</Label>
<Input
id="title"
value={form.title}
onChange={(e) => set("title", e.target.value)}
placeholder="موضوع أو عنوان الاجتماع"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="requesterPhone">
رقم الهاتف للتواصل <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterPhone"
type="tel"
inputMode="tel"
dir="ltr"
className="text-right"
value={form.requesterPhone}
onChange={(e) => set("requesterPhone", e.target.value)}
placeholder="05xxxxxxxx"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="requesterOrg">الجهة / المؤسسة</Label>
<Input
id="requesterOrg"
value={form.requesterOrg}
onChange={(e) => set("requesterOrg", e.target.value)}
placeholder="اسم الجهة أو المؤسسة (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendeeCount">عدد الحضور</Label>
<Input
id="attendeeCount"
type="number"
inputMode="numeric"
min={1}
value={form.attendeeCount}
onChange={(e) => set("attendeeCount", e.target.value)}
placeholder="العدد المتوقع للحضور (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="room">
القاعة <span className="text-rose-500">*</span>
</Label>
<Select
value={form.roomId}
onValueChange={(v) => set("roomId", v)}
>
<SelectTrigger id="room">
<SelectValue
placeholder={
rooms.isLoading ? "جارٍ التحميل…" : "اختر القاعة"
}
/>
</SelectTrigger>
<SelectContent>
{(rooms.data ?? []).map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.nameAr || r.nameEn}
</SelectItem>
))}
</SelectContent>
</Select>
{rooms.isError && (
<p className="text-xs text-rose-500">
تعذّر تحميل قائمة القاعات، يرجى تحديث الصفحة.
</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startsAt">
من <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="startsAt"
value={form.startsAt}
onChange={(v) => set("startsAt", v)}
placeholder="اختر تاريخ ووقت البداية"
/>
</div>
<div className="space-y-2">
<Label htmlFor="endsAt">
إلى <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="endsAt"
value={form.endsAt}
onChange={(v) => set("endsAt", v)}
placeholder="اختر تاريخ ووقت النهاية"
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
/>
</div>
</div>
{form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) >= new Date(form.endsAt) && (
<p className="text-xs text-rose-500 -mt-2">
يجب أن يكون وقت البداية قبل وقت النهاية.
</p>
)}
<div className="space-y-2">
<Label htmlFor="notes">ملاحظات</Label>
<Textarea
id="notes"
value={form.notes}
onChange={(e) => set("notes", e.target.value)}
placeholder="أي تفاصيل إضافية (اختياري)"
rows={3}
/>
</div>
{error && (
<div className="rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2">
{error}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={!canSubmit || submitting}
>
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
إرسال الطلب
</Button>
</form>
</div>
</div>
);
}
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";
+309
View File
@@ -0,0 +1,309 @@
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 }),
// Guest-supplied contact phone. Only populated for public
// (self-service) booking requests; internal bookings leave it null.
requesterPhone: varchar("requester_phone", { length: 40 }),
// The requester's organization / entity (الجهة). Optional even for
// public requests; always null for internal bookings unless set.
requesterOrg: varchar("requester_org", { length: 300 }),
// Expected number of attendees (عدد الحضور). Optional; supplied by the
// guest on a public request or by staff on an internal booking.
attendeeCount: integer("attendee_count"),
// Provenance marker. "internal" for bookings created by authenticated
// protocol staff through the app; "public" for guest requests coming in
// via the no-login booking-request link. Defaults to "internal" so all
// pre-existing rows are treated as internal.
source: varchar("source", { length: 20 }).notNull().default("internal"),
// 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 }),
// Lifecycle: pending -> approved | rejected, then optionally
// completed | cancelled. Mirrors the booking / gift-issue approval flow.
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) => ({
startIdx: index("protocol_ext_meetings_start_idx").on(t.startsAt),
statusIdx: index("protocol_ext_meetings_status_idx").on(t.status),
}),
);
// ---------------------------------------------------------------------------
// 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;
+206 -1
View File
@@ -18,8 +18,9 @@ import {
executiveMeetingsTable,
executiveMeetingAttendeesTable,
executiveMeetingNotificationsTable,
protocolRoomsTable,
} from "@workspace/db";
import { eq, sql } from "drizzle-orm";
import { eq, inArray, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
async function main() {
@@ -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,196 @@ 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");
// Scoped protocol permissions. `protocol.access` also gates the home-screen
// tile (via app_permissions); the rest guard individual route capabilities.
// Route middleware authorizes by these permissions, never by role name, so
// an admin can delegate any capability by granting the permission.
const protocolPermissions = [
{
name: "protocol.access",
descriptionAr: "الوصول إلى وحدة العلاقات العامة والمراسم",
descriptionEn: "Access the Public Relations & Protocol module",
},
{
name: "protocol.request",
descriptionAr: "تقديم طلبات الحجز وإصدار الدروع",
descriptionEn: "Submit protocol booking and gift-issue requests",
},
{
name: "protocol.mutate",
descriptionAr: "إنشاء وتعديل سجلات العلاقات العامة والمراسم",
descriptionEn: "Create and edit protocol records",
},
{
name: "protocol.approve",
descriptionAr: "اعتماد أو رفض الحجوزات وإصدارات الدروع",
descriptionEn: "Approve or reject protocol bookings and gift issues",
},
{
name: "protocol.rooms.manage",
descriptionAr: "إدارة سجل القاعات",
descriptionEn: "Manage the protocol rooms registry",
},
{
name: "protocol.audit.read",
descriptionAr: "عرض سجل تدقيق العلاقات العامة والمراسم",
descriptionEn: "View the protocol audit log",
},
];
await db
.insert(permissionsTable)
.values(protocolPermissions)
.onConflictDoNothing();
// Map each protocol permission to the roles that should hold it. Capabilities
// are additive: super admin holds everything, viewer only reads.
const protocolRolePermMap: Record<string, string[]> = {
"protocol.access": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
"protocol_officer",
"protocol_requester",
"protocol_viewer",
],
"protocol.request": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
"protocol_officer",
"protocol_requester",
],
"protocol.mutate": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
"protocol_officer",
],
"protocol.approve": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
],
"protocol.rooms.manage": ["admin", "protocol_super_admin"],
"protocol.audit.read": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
],
};
const allRolesForProtocol = await db.select().from(rolesTable);
const roleIdByName = new Map(allRolesForProtocol.map((r) => [r.name, r.id]));
const allProtocolPerms = await db
.select()
.from(permissionsTable)
.where(
inArray(
permissionsTable.name,
protocolPermissions.map((p) => p.name),
),
);
const permIdByName = new Map(allProtocolPerms.map((p) => [p.name, p.id]));
const rolePermValues: { roleId: number; permissionId: number }[] = [];
for (const [permName, roleNames] of Object.entries(protocolRolePermMap)) {
const permId = permIdByName.get(permName);
if (permId === undefined) continue;
for (const roleName of roleNames) {
const roleId = roleIdByName.get(roleName);
if (roleId === undefined) continue;
rolePermValues.push({ roleId, permissionId: permId });
}
}
if (rolePermValues.length > 0) {
await db
.insert(rolePermissionsTable)
.values(rolePermValues)
.onConflictDoNothing();
}
console.log("Protocol scoped permissions mapped to roles");
const protocolAccessPermId = permIdByName.get("protocol.access");
if (protocolAccessPermId !== undefined) {
const [protocolApp] = await db
.select()
.from(appsTable)
.where(eq(appsTable.slug, "protocol"));
if (protocolApp) {
await db
.insert(appPermissionsTable)
.values({ appId: protocolApp.id, permissionId: protocolAccessPermId })
.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: "Main Meeting Hall",
sortOrder: 1,
},
{
nameAr: "قاعة كبار الزوار",
nameEn: "VIP Guests Hall",
sortOrder: 2,
},
{
nameAr: "قاعة الاجتماعات التنفيذية",
nameEn: "Executive Meetings Hall",
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