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