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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user