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