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:
Replit Agent
2026-07-07 12:34:52 +00:00
parent 1b93c2cf02
commit 199f0c025e
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) {