From 62c38a508f3554f18450af94533eb8ad697c0bf2 Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Mon, 6 Jul 2026 11:18:14 +0000 Subject: [PATCH] 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 --- artifacts/api-server/src/routes/protocol.ts | 154 ++++++++ artifacts/tx-os/src/App.tsx | 12 +- artifacts/tx-os/src/locales/ar.json | 3 + artifacts/tx-os/src/locales/en.json | 3 + .../tx-os/src/pages/protocol-request.tsx | 345 ++++++++++++++++++ artifacts/tx-os/src/pages/protocol.tsx | 37 +- lib/db/src/schema/protocol.ts | 11 + 7 files changed, 556 insertions(+), 9 deletions(-) create mode 100644 artifacts/tx-os/src/pages/protocol-request.tsx diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index 8cfd61cd..7e077651 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index 373171ac..01a50a05 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -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() { - + {/* The public room booking request form must render for logged-OUT + guests, so it lives OUTSIDE (and thus outside + AuthProvider, which redirects unauthenticated users to /login). + The catch-all falls through to the authenticated app. */} + + + + + + diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 380b0602..a8850c39 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1712,6 +1712,9 @@ "edit": "تعديل الحجز", "empty": "لا توجد حجوزات.", "requester": "مقدّم الطلب", + "org": "الجهة", + "phone": "الهاتف", + "publicRequest": "طلب عام", "roomLabel": "القاعة", "titleLabel": "عنوان الحجز", "startsAt": "من", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 3bb4acc9..9d19c8fd 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/protocol-request.tsx b/artifacts/tx-os/src/pages/protocol-request.tsx new file mode 100644 index 00000000..af477b52 --- /dev/null +++ b/artifacts/tx-os/src/pages/protocol-request.tsx @@ -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({ + 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(EMPTY); + const [submitting, setSubmitting] = useState(false); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + + const set = (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 ( +
+
+ +

+ تم استلام طلبك +

+

+ سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف + المُدخل لتأكيد الموعد. +

+ +
+
+ ); + } + + return ( +
+
+
+
+ +
+
+

طلب حجز قاعة

+

+ يرجى تعبئة البيانات وسيتواصل معك فريق المراسم للتأكيد. +

+
+
+ +
+ {/* Honeypot: hidden from real users, catches bots. */} + + +
+ + set("requesterName", e.target.value)} + placeholder="الاسم الثلاثي" + required + /> +
+ +
+ + set("title", e.target.value)} + placeholder="موضوع أو عنوان الاجتماع" + required + /> +
+ +
+ + set("requesterPhone", e.target.value)} + placeholder="05xxxxxxxx" + required + /> +
+ +
+ + set("requesterOrg", e.target.value)} + placeholder="اسم الجهة أو المؤسسة (اختياري)" + /> +
+ +
+ + + {rooms.isError && ( +

+ تعذّر تحميل قائمة القاعات، يرجى تحديث الصفحة. +

+ )} +
+ +
+
+ + set("startsAt", e.target.value)} + required + /> +
+
+ + set("endsAt", e.target.value)} + required + /> +
+
+ {form.startsAt !== "" && + form.endsAt !== "" && + new Date(form.startsAt) >= new Date(form.endsAt) && ( +

+ يجب أن يكون وقت البداية قبل وقت النهاية. +

+ )} + +
+ +