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.

Each booking now gets an auto-generated searchable reference
(RB-{year}-{pad4}) 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 (unique) + requesterPhone index; new enums/types.
  Applied via drizzle push; backfilled existing 4 rows.
- api-server: extended public booking schema + refine (org required when
  external), makeBookingReference, public/internal POST generate+return
  bookingReference.
- tx-os public form rewritten; admin card shows new fields + client-side
  search with empty state.
- ar/en i18n keys under protocol.bookings.

Notes:
- Pre-existing push.ts typecheck errors are unrelated to this task.
- Architect review: PASS, no blocking issues.
This commit is contained in:
Replit Agent
2026-07-07 12:26:19 +00:00
parent 553df28571
commit 1b93c2cf02
6 changed files with 524 additions and 73 deletions
+67 -11
View File
@@ -165,6 +165,14 @@ const roomPatchSchema = roomCreateSchema.partial().refine(
{ message: "no fields to update" },
);
// A single "key attendee" (أبرز الحضور) row.
const keyAttendeeSchema = z.object({
name: z.string().trim().min(1).max(300),
position: z.string().trim().max(300).default(""),
side: z.enum(["internal", "external"]),
});
const keyAttendeesSchema = z.array(keyAttendeeSchema).max(100);
const bookingCreateSchema = z
.object({
roomId: z.number().int().positive(),
@@ -201,8 +209,14 @@ const publicBookingRequestSchema = z
title: z.string().trim().min(1).max(500),
requesterName: z.string().trim().min(1).max(300),
requesterPhone: z.string().trim().min(3).max(40),
// The external entity name (اسم الجهة). Required when meetingType is
// "external"; ignored/null for internal meetings.
requesterOrg: z.string().trim().max(300).nullable().optional(),
department: z.string().trim().max(300).nullable().optional(),
meetingType: z.enum(["internal", "external"]).default("internal"),
purpose: z.string().trim().max(5000).nullable().optional(),
attendeeCount: z.number().int().positive().max(100000).nullable().optional(),
keyAttendees: keyAttendeesSchema.optional().default([]),
startsAt: isoDateTime,
endsAt: isoDateTime,
notes: z.string().trim().max(5000).nullable().optional(),
@@ -214,7 +228,18 @@ const publicBookingRequestSchema = z
.refine((v) => new Date(v.startsAt) < new Date(v.endsAt), {
message: "startsAt must be before endsAt",
path: ["endsAt"],
});
})
// When the meeting is with an external entity, its name (اسم الجهة) is
// required.
.refine(
(v) =>
v.meetingType !== "external" ||
(typeof v.requesterOrg === "string" && v.requesterOrg.trim().length > 0),
{
message: "requesterOrg is required for external meetings",
path: ["requesterOrg"],
},
);
const rejectSchema = z.object({
reason: z.string().trim().max(1000).nullable().optional(),
@@ -323,6 +348,13 @@ async function lockRoomRow(
return rows.length > 0;
}
// Build a human-friendly booking reference (رقم الحجز) from a row id and its
// creation date, e.g. "RB-2026-0042". Deterministic + unique because the id
// is unique.
function makeBookingReference(id: number, when: Date): string {
return `RB-${when.getFullYear()}-${String(id).padStart(4, "0")}`;
}
async function hasBookingConflict(
executor: DbExecutor,
opts: {
@@ -462,6 +494,7 @@ router.post(
) {
throw new Error("__conflict__");
}
const isExternal = body.meetingType === "external";
const [row] = await tx
.insert(protocolRoomBookingsTable)
.values({
@@ -469,8 +502,16 @@ router.post(
title: body.title,
requesterName: body.requesterName,
requesterPhone: body.requesterPhone,
requesterOrg: body.requesterOrg ?? null,
requesterOrg: isExternal ? (body.requesterOrg ?? null) : null,
department: body.department ?? null,
meetingType: body.meetingType,
purpose: body.purpose ?? null,
attendeeCount: body.attendeeCount ?? null,
keyAttendees: (body.keyAttendees ?? []).map((a) => ({
name: a.name,
position: a.position ?? "",
side: a.side,
})),
startsAt,
endsAt,
status: "pending",
@@ -479,18 +520,27 @@ 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: row.id,
newValue: row,
entityId: withRef.id,
newValue: withRef,
performedBy: null,
});
return row;
return withRef;
});
// 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 });
// Deliberately return only an acknowledgement plus the booking
// reference — never the full stored row (which could leak internal ids
// / other data) to an anonymous caller.
res
.status(201)
.json({ ok: true, id: created.id, bookingReference: created.bookingReference });
} catch (e) {
if (e instanceof Error && e.message === "__conflict__") {
res
@@ -710,14 +760,20 @@ 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: row.id,
newValue: row,
entityId: withRef.id,
newValue: withRef,
performedBy: req.session.userId!,
});
return row;
return withRef;
});
res.status(201).json(created);
} catch (e) {