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
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
import { and, asc, desc, eq, gte, inArray, lt, lte, ne, sql } from "drizzle-orm";
|
||||
import { z, type ZodType } from "zod";
|
||||
import { db } from "@workspace/db";
|
||||
@@ -180,6 +181,29 @@ const bookingPatchSchema = z
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });
|
||||
|
||||
// Public (no-login) booking request. Stricter than the internal schema:
|
||||
// full name, contact phone and title are all REQUIRED, and a hidden
|
||||
// honeypot field (`website`) must stay empty — a filled honeypot is a bot.
|
||||
const publicBookingRequestSchema = z
|
||||
.object({
|
||||
roomId: z.number().int().positive(),
|
||||
title: z.string().trim().min(1).max(500),
|
||||
requesterName: z.string().trim().min(1).max(300),
|
||||
requesterPhone: z.string().trim().min(3).max(40),
|
||||
requesterOrg: z.string().trim().max(300).nullable().optional(),
|
||||
startsAt: isoDateTime,
|
||||
endsAt: isoDateTime,
|
||||
notes: z.string().trim().max(5000).nullable().optional(),
|
||||
// Honeypot: real users never see or fill this. We accept any value at
|
||||
// the schema level (so a bot gets no "invalid field" signal) and handle
|
||||
// a non-empty value in the route by silently pretending success.
|
||||
website: z.string().max(200).optional(),
|
||||
})
|
||||
.refine((v) => new Date(v.startsAt) < new Date(v.endsAt), {
|
||||
message: "startsAt must be before endsAt",
|
||||
path: ["endsAt"],
|
||||
});
|
||||
|
||||
const rejectSchema = z.object({
|
||||
reason: z.string().trim().max(1000).nullable().optional(),
|
||||
});
|
||||
@@ -340,6 +364,136 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public (no-login) booking request link
|
||||
//
|
||||
// These two endpoints power the guest-facing room booking request form. They
|
||||
// are intentionally NOT guarded by requireProtocolAccess so anyone with the
|
||||
// link can pick a room and submit a request. Requests are always created as
|
||||
// `pending` + `source = "public"` with no createdBy, and land in the exact
|
||||
// same Protocol staff confirm/reject flow as internal pending bookings.
|
||||
//
|
||||
// Abuse is limited by (a) an IP rate limiter, (b) a hidden honeypot field,
|
||||
// and (c) never exposing the room schedule — a guest only sees the active
|
||||
// room list, never who booked what or when.
|
||||
// ---------------------------------------------------------------------------
|
||||
const publicBookingLimiter = rateLimit({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 8,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
// Key on the proxy-derived client IP. The app sets `trust proxy = 1`, so
|
||||
// `req.ip` is the address inserted by the single trusted edge proxy and is
|
||||
// NOT client-spoofable via X-Forwarded-For. `ipKeyGenerator` also applies
|
||||
// the correct IPv6 subnet normalization so a single IPv6 client cannot
|
||||
// rotate through addresses within its /64 to defeat the limit.
|
||||
keyGenerator: (req: Request) => ipKeyGenerator(req.ip ?? ""),
|
||||
message: {
|
||||
error: "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.",
|
||||
code: "rate_limited",
|
||||
},
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/protocol/public/rooms",
|
||||
async (_req: Request, res: Response) => {
|
||||
// Only the minimal fields a guest needs to choose a room. No capacity /
|
||||
// location / audit fields, and only active rooms.
|
||||
const rows = await db
|
||||
.select({
|
||||
id: protocolRoomsTable.id,
|
||||
nameAr: protocolRoomsTable.nameAr,
|
||||
nameEn: protocolRoomsTable.nameEn,
|
||||
})
|
||||
.from(protocolRoomsTable)
|
||||
.where(eq(protocolRoomsTable.isActive, true))
|
||||
.orderBy(asc(protocolRoomsTable.sortOrder), asc(protocolRoomsTable.id));
|
||||
res.json(rows);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/protocol/public/booking-requests",
|
||||
publicBookingLimiter,
|
||||
async (req: Request, res: Response) => {
|
||||
const body = parseBody(res, publicBookingRequestSchema, req.body);
|
||||
if (!body) return;
|
||||
// Honeypot tripped — pretend success so bots get no signal, but never
|
||||
// write anything.
|
||||
if (body.website) {
|
||||
res.status(201).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
const startsAt = new Date(body.startsAt);
|
||||
const endsAt = new Date(body.endsAt);
|
||||
|
||||
const [room] = await db
|
||||
.select({ id: protocolRoomsTable.id, isActive: protocolRoomsTable.isActive })
|
||||
.from(protocolRoomsTable)
|
||||
.where(eq(protocolRoomsTable.id, body.roomId));
|
||||
if (!room || !room.isActive) {
|
||||
res.status(400).json({ error: "room not found", code: "invalid_room" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await db.transaction(async (tx) => {
|
||||
if (!(await lockRoomRow(tx, body.roomId))) {
|
||||
throw new Error("__room_missing__");
|
||||
}
|
||||
if (
|
||||
await hasBookingConflict(tx, {
|
||||
roomId: body.roomId,
|
||||
startsAt,
|
||||
endsAt,
|
||||
})
|
||||
) {
|
||||
throw new Error("__conflict__");
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(protocolRoomBookingsTable)
|
||||
.values({
|
||||
roomId: body.roomId,
|
||||
title: body.title,
|
||||
requesterName: body.requesterName,
|
||||
requesterPhone: body.requesterPhone,
|
||||
requesterOrg: body.requesterOrg ?? null,
|
||||
startsAt,
|
||||
endsAt,
|
||||
status: "pending",
|
||||
source: "public",
|
||||
notes: body.notes ?? null,
|
||||
createdBy: null,
|
||||
})
|
||||
.returning();
|
||||
await logAudit(tx, {
|
||||
action: "booking.public_request",
|
||||
entityType: "booking",
|
||||
entityId: row.id,
|
||||
newValue: row,
|
||||
performedBy: null,
|
||||
});
|
||||
return row;
|
||||
});
|
||||
// Deliberately return only an acknowledgement — never the stored row
|
||||
// (which could leak internal ids / other data) to an anonymous caller.
|
||||
res.status(201).json({ ok: true, id: created.id });
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "__conflict__") {
|
||||
res
|
||||
.status(409)
|
||||
.json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" });
|
||||
return;
|
||||
}
|
||||
if (e instanceof Error && e.message === "__room_missing__") {
|
||||
res.status(400).json({ error: "room not found", code: "invalid_room" });
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rooms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -28,6 +28,7 @@ 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({
|
||||
@@ -97,7 +98,16 @@ function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||
<Router />
|
||||
{/* 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>
|
||||
|
||||
@@ -1712,6 +1712,9 @@
|
||||
"edit": "تعديل الحجز",
|
||||
"empty": "لا توجد حجوزات.",
|
||||
"requester": "مقدّم الطلب",
|
||||
"org": "الجهة",
|
||||
"phone": "الهاتف",
|
||||
"publicRequest": "طلب عام",
|
||||
"roomLabel": "القاعة",
|
||||
"titleLabel": "عنوان الحجز",
|
||||
"startsAt": "من",
|
||||
|
||||
@@ -1585,6 +1585,9 @@
|
||||
"edit": "Edit booking",
|
||||
"empty": "No bookings yet.",
|
||||
"requester": "Requester",
|
||||
"org": "Organization",
|
||||
"phone": "Phone",
|
||||
"publicRequest": "Public request",
|
||||
"roomLabel": "Room",
|
||||
"titleLabel": "Booking title",
|
||||
"startsAt": "From",
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
roomId: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
notes: string;
|
||||
website: string; // honeypot
|
||||
};
|
||||
|
||||
const EMPTY: FormState = {
|
||||
requesterName: "",
|
||||
title: "",
|
||||
requesterPhone: "",
|
||||
requesterOrg: "",
|
||||
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,
|
||||
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="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>
|
||||
<Input
|
||||
id="startsAt"
|
||||
type="datetime-local"
|
||||
value={form.startsAt}
|
||||
onChange={(e) => set("startsAt", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endsAt">
|
||||
إلى <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="endsAt"
|
||||
type="datetime-local"
|
||||
value={form.endsAt}
|
||||
onChange={(e) => set("endsAt", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,9 @@ type Booking = {
|
||||
roomId: number;
|
||||
title: string;
|
||||
requesterName: string | null;
|
||||
requesterPhone: string | null;
|
||||
requesterOrg: string | null;
|
||||
source: "internal" | "public";
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
status: BookingStatus;
|
||||
@@ -483,15 +486,33 @@ export default function ProtocolPage() {
|
||||
{t("protocol.bookings.requester")}: {b.requesterName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full shrink-0",
|
||||
STATUS_PILL[b.status],
|
||||
{b.requesterOrg && (
|
||||
<div className="text-xs text-slate-400">
|
||||
{t("protocol.bookings.org")}: {b.requesterOrg}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{t(`protocol.status.${b.status}`)}
|
||||
</span>
|
||||
{b.requesterPhone && (
|
||||
<div className="text-xs text-slate-400">
|
||||
{t("protocol.bookings.phone")}:{" "}
|
||||
<span dir="ltr">{b.requesterPhone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{b.source === "public" && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-sky-100 text-sky-700">
|
||||
{t("protocol.bookings.publicRequest")}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
STATUS_PILL[b.status],
|
||||
)}
|
||||
>
|
||||
{t(`protocol.status.${b.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{c?.canApprove && b.status === "pending" && (
|
||||
|
||||
@@ -88,6 +88,17 @@ export const protocolRoomBookingsTable = pgTable(
|
||||
.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 }),
|
||||
// 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(),
|
||||
|
||||
Reference in New Issue
Block a user