diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index 1ea9ec07..ed550bfc 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -1,6 +1,19 @@ 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 { + and, + asc, + desc, + eq, + gte, + ilike, + inArray, + lt, + lte, + ne, + or, + sql, +} from "drizzle-orm"; import { z, type ZodType } from "zod"; import { db } from "@workspace/db"; import { @@ -355,6 +368,21 @@ function makeBookingReference(id: number, when: Date): string { return `RB-${when.getFullYear()}-${String(id).padStart(4, "0")}`; } +// Reserve the next booking id from the table's serial sequence so we can +// compute the booking reference and store it in the same INSERT (the column +// is NOT NULL). Advancing the sequence here keeps it in sync with the +// explicit id we insert. +async function reserveBookingId( + tx: Parameters[0]>[0], +): Promise { + const result = await tx.execute( + sql`SELECT nextval(pg_get_serial_sequence('protocol_room_bookings', 'id')) AS id`, + ); + const rows = (result as unknown as { rows: Array<{ id: number | string }> }) + .rows; + return Number(rows[0].id); +} + async function hasBookingConflict( executor: DbExecutor, opts: { @@ -495,9 +523,11 @@ router.post( throw new Error("__conflict__"); } const isExternal = body.meetingType === "external"; + const bookingId = await reserveBookingId(tx); const [row] = await tx .insert(protocolRoomBookingsTable) .values({ + id: bookingId, roomId: body.roomId, title: body.title, requesterName: body.requesterName, @@ -512,6 +542,7 @@ router.post( position: a.position ?? "", side: a.side, })), + bookingReference: makeBookingReference(bookingId, new Date()), startsAt, endsAt, status: "pending", @@ -520,20 +551,14 @@ router.post( createdBy: null, }) .returning(); - const bookingReference = makeBookingReference(row.id, row.createdAt); - const [withRef] = await tx - .update(protocolRoomBookingsTable) - .set({ bookingReference }) - .where(eq(protocolRoomBookingsTable.id, row.id)) - .returning(); await logAudit(tx, { action: "booking.public_request", entityType: "booking", - entityId: withRef.id, - newValue: withRef, + entityId: row.id, + newValue: row, performedBy: null, }); - return withRef; + return row; }); // Deliberately return only an acknowledgement plus the booking // reference — never the full stored row (which could leak internal ids @@ -694,6 +719,18 @@ router.get( if (typeof toRaw === "string" && !Number.isNaN(Date.parse(toRaw))) { conds.push(lte(protocolRoomBookingsTable.startsAt, new Date(toRaw))); } + // Free-text staff search by booking reference (رقم الحجز) OR requester + // phone. Both are indexed; matched case-insensitively as a substring. + const qRaw = req.query.q; + if (typeof qRaw === "string" && qRaw.trim() !== "") { + const term = `%${qRaw.trim()}%`; + conds.push( + or( + ilike(protocolRoomBookingsTable.bookingReference, term), + ilike(protocolRoomBookingsTable.requesterPhone, term), + )!, + ); + } const rows = await db .select() .from(protocolRoomBookingsTable) @@ -744,13 +781,16 @@ router.post( ) { throw new Error("__conflict__"); } + const bookingId = await reserveBookingId(tx); const [row] = await tx .insert(protocolRoomBookingsTable) .values({ + id: bookingId, roomId: body.roomId, title: body.title, requesterName: body.requesterName ?? null, attendeeCount: body.attendeeCount ?? null, + bookingReference: makeBookingReference(bookingId, new Date()), startsAt, endsAt, status: canApprove ? "approved" : "pending", @@ -760,20 +800,14 @@ router.post( approvedAt: canApprove ? new Date() : null, }) .returning(); - const bookingReference = makeBookingReference(row.id, row.createdAt); - const [withRef] = await tx - .update(protocolRoomBookingsTable) - .set({ bookingReference }) - .where(eq(protocolRoomBookingsTable.id, row.id)) - .returning(); await logAudit(tx, { action: "booking.create", entityType: "booking", - entityId: withRef.id, - newValue: withRef, + entityId: row.id, + newValue: row, performedBy: req.session.userId!, }); - return withRef; + return row; }); res.status(201).json(created); } catch (e) { diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index 17fae2fa..1b684b3d 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useRoute } from "wouter"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -493,9 +493,25 @@ export default function ProtocolPage() { queryKey: ["protocol", "rooms"], queryFn: () => apiJson(`${API}/protocol/rooms`), }); + const [bookingSearch, setBookingSearch] = useState(""); + const [debouncedBookingSearch, setDebouncedBookingSearch] = useState(""); + useEffect(() => { + const id = setTimeout( + () => setDebouncedBookingSearch(bookingSearch.trim()), + 300, + ); + return () => clearTimeout(id); + }, [bookingSearch]); const bookings = useQuery({ - queryKey: ["protocol", "bookings"], - queryFn: () => apiJson(`${API}/protocol/bookings`), + queryKey: ["protocol", "bookings", debouncedBookingSearch], + queryFn: () => + apiJson( + `${API}/protocol/bookings${ + debouncedBookingSearch + ? `?q=${encodeURIComponent(debouncedBookingSearch)}` + : "" + }`, + ), }); const external = useQuery({ queryKey: ["protocol", "external"], @@ -576,7 +592,6 @@ export default function ProtocolPage() { id: number; } | null>(null); const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list"); - const [bookingSearch, setBookingSearch] = useState(""); const [selectedDay, setSelectedDay] = useState(() => new Date()); const [calendarMonth, setCalendarMonth] = useState(() => new Date()); const now = useNow(60000); @@ -1071,22 +1086,13 @@ export default function ProtocolPage() { /> )} - {bookings.data?.length === 0 && ( + {bookings.data?.length === 0 && !debouncedBookingSearch && ( )} {bookingsView === "list" ? ( (() => { - const q = bookingSearch.trim().toLowerCase(); - const filtered = q - ? (bookings.data ?? []).filter( - (b) => - (b.bookingReference ?? "") - .toLowerCase() - .includes(q) || - (b.requesterPhone ?? "").toLowerCase().includes(q), - ) - : (bookings.data ?? []); - if (q && filtered.length === 0) { + const list = bookings.data ?? []; + if (debouncedBookingSearch && list.length === 0) { return ( - {filtered.map((b) => renderBookingCard(b))} + {list.map((b) => renderBookingCard(b))} ); })() diff --git a/lib/db/src/schema/protocol.ts b/lib/db/src/schema/protocol.ts index 89f69817..643694dc 100644 --- a/lib/db/src/schema/protocol.ts +++ b/lib/db/src/schema/protocol.ts @@ -132,8 +132,9 @@ export const protocolRoomBookingsTable = pgTable( .notNull() .default([]), // Human-friendly, unique booking reference (رقم الحجز) shown to the - // requester on success and searchable by staff. Generated on insert. - bookingReference: varchar("booking_reference", { length: 40 }), + // requester on success and searchable by staff. Generated on insert and + // never null — every booking (public or internal) has one. + bookingReference: varchar("booking_reference", { length: 40 }).notNull(), // 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