Task #670: revamp public room-booking form (protocol)

Public booking form now captures department, meeting type (internal/
external), meeting purpose, and a repeatable "key attendees" table
(name + position + side). External meetings require the entity name
(reuses requesterOrg). "عدد الحضور" relabeled to "العدد المتوقع للحضور".
Added a static red disclaimer note about possible cancellation.

Every booking (public + internal) now gets a unique, NOT NULL, searchable
reference (RB-{year}-{pad4(id)}) shown on the success screen. Staff can
search the admin bookings list by reference OR phone.

Changes:
- lib/db schema: department, meetingType, purpose, keyAttendees (jsonb),
  bookingReference (NOT NULL + unique) + requesterPhone index; new
  enums/types. Applied via drizzle push; existing rows backfilled.
- api-server: extended public booking schema + refine (org required when
  external); reserveBookingId (nextval on the serial sequence) so the
  reference is written in a single INSERT and can never be null; GET
  /protocol/bookings accepts a `q` param filtering by reference OR phone
  (server-side, indexed, case-insensitive).
- tx-os public form rewritten; admin card shows new fields; admin search
  box now debounced and wired to the API `q` param (server-side filtering)
  with an empty state.
- ar/en i18n keys under protocol.bookings.

Verification:
- Public booking POST returns a reference; external-without-org rejected
  (400); DB has zero null references after push.
- Pre-existing push.ts / executive-meetings.ts typecheck errors are
  unrelated to this task (files untouched).
This commit is contained in:
Riyadh
2026-07-07 12:34:52 +00:00
parent 3ff1338c7e
commit dd2816a1a9
3 changed files with 79 additions and 38 deletions
+53 -19
View File
@@ -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<Parameters<typeof db.transaction>[0]>[0],
): Promise<number> {
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) {
+23 -17
View File
@@ -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<Room[]>(`${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<Booking[]>({
queryKey: ["protocol", "bookings"],
queryFn: () => apiJson<Booking[]>(`${API}/protocol/bookings`),
queryKey: ["protocol", "bookings", debouncedBookingSearch],
queryFn: () =>
apiJson<Booking[]>(
`${API}/protocol/bookings${
debouncedBookingSearch
? `?q=${encodeURIComponent(debouncedBookingSearch)}`
: ""
}`,
),
});
const external = useQuery<ExternalMeeting[]>({
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<Date>(() => new Date());
const [calendarMonth, setCalendarMonth] = useState<Date>(() => new Date());
const now = useNow(60000);
@@ -1071,22 +1086,13 @@ export default function ProtocolPage() {
/>
</div>
)}
{bookings.data?.length === 0 && (
{bookings.data?.length === 0 && !debouncedBookingSearch && (
<EmptyState text={t("protocol.bookings.empty")} />
)}
{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 (
<EmptyState
text={t("protocol.bookings.searchEmpty")}
@@ -1095,7 +1101,7 @@ export default function ProtocolPage() {
}
return (
<div className="grid gap-2">
{filtered.map((b) => renderBookingCard(b))}
{list.map((b) => renderBookingCard(b))}
</div>
);
})()
+3 -2
View File
@@ -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