Task #649: complete Public Relations & Protocol module (fix 8 review gaps)

Addresses the 8 spec gaps that blocked prior completion, all additive and
scoped to the protocol_* module / /protocol routes. executive-meetings untouched.

Backend (api-server/routes/protocol.ts, lib/db schema, seed):
- External meetings create as "pending" (status dropped from create schema);
  added approve + reject endpoints with state-conditional WHERE status='pending'
  and 409 on contention; patch status enum restricted to completed/cancelled.
- Added approvedBy/approvedAt/rejectionReason columns + status index; default
  status now "pending".
- Booking reject and gift-issue reject made state-conditional (409 on 0 rows).
- Dashboard: roomsAvailableNow (NOT EXISTS), giftsIssuedThisMonth/Qty;
  upcomingExternal filtered to future pending|approved.
- Reports: added externalMeetings summary counts by status.
- Seed: room names corrected (AR + EN).

Frontend (tx-os/pages/protocol.tsx, App.tsx, locales):
- URL-synced tabs under /protocol/:tab (slug<->tab maps).
- Dedicated Rooms tab (moved out of Gifts); RoomDialog isActive toggle.
- External approve/reject buttons + rejectionReason display; ExternalDialog
  create omits status, edit limited to completed/cancelled.
- Dashboard cards clickable to navigate; reports external section.
- Bookings list/calendar (grouped-by-day) view toggle.
- New i18n keys in ar.json + en.json.

Verification: tx-os + protocol.ts typecheck clean; drizzle push + re-seed done;
both workflows restarted; /api/protocol/me returns 401 unauth; architect
re-review approved. Pre-existing typecheck errors in executive-meetings.ts and
push.ts are unrelated and were not touched.

Note: had to rebuild lib/db declarations (tsc --build --force) so api-server
project references picked up the new schema columns.
This commit is contained in:
Replit Agent
2026-07-06 09:46:12 +00:00
parent 2322b77b8d
commit 5e460920c7
7 changed files with 685 additions and 220 deletions
+197 -8
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter } from "express";
import { and, asc, desc, eq, gte, inArray, lte, ne, sql } from "drizzle-orm";
import { and, asc, desc, eq, gte, inArray, lt, lte, ne, sql } from "drizzle-orm";
import { z, type ZodType } from "zod";
import { db } from "@workspace/db";
import {
@@ -169,7 +169,6 @@ const externalCreateSchema = z
location: z.string().trim().max(300).nullable().optional(),
startsAt: isoDateTime,
endsAt: isoDateTime.nullable().optional(),
status: z.enum(["scheduled", "completed", "cancelled"]).optional(),
notes: z.string().trim().max(5000).nullable().optional(),
})
.refine(
@@ -177,6 +176,9 @@ const externalCreateSchema = z
{ message: "startsAt must be before endsAt", path: ["endsAt"] },
);
// Edits may only mark an already-decided meeting completed/cancelled; the
// pending -> approved | rejected transition goes through the dedicated
// approve/reject endpoints so it stays state-conditional.
const externalPatchSchema = z
.object({
title: z.string().trim().min(1).max(500).optional(),
@@ -184,7 +186,7 @@ const externalPatchSchema = z
location: z.string().trim().max(300).nullable().optional(),
startsAt: isoDateTime.optional(),
endsAt: isoDateTime.nullable().optional(),
status: z.enum(["scheduled", "completed", "cancelled"]).optional(),
status: z.enum(["completed", "cancelled"]).optional(),
notes: z.string().trim().max(5000).nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });
@@ -722,6 +724,8 @@ router.post(
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
// Only a pending booking can be rejected; the state-conditional update
// makes this atomic against a concurrent approve/cancel.
const [row] = await db
.update(protocolRoomBookingsTable)
.set({
@@ -730,8 +734,19 @@ router.post(
approvedAt: new Date(),
rejectionReason: body.reason ?? null,
})
.where(eq(protocolRoomBookingsTable.id, id))
.where(
and(
eq(protocolRoomBookingsTable.id, id),
eq(protocolRoomBookingsTable.status, "pending"),
),
)
.returning();
if (!row) {
res
.status(409)
.json({ error: "booking is not pending", code: "invalid_status" });
return;
}
await logAudit(db, {
action: "booking.reject",
entityType: "booking",
@@ -830,7 +845,7 @@ router.post(
location: body.location ?? null,
startsAt: new Date(body.startsAt),
endsAt: body.endsAt ? new Date(body.endsAt) : null,
status: body.status ?? "scheduled",
status: "pending",
notes: body.notes ?? null,
createdBy: req.session.userId!,
})
@@ -917,6 +932,101 @@ router.delete(
},
);
router.post(
"/protocol/external-meetings/:id/approve",
requireApprove,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
const [existing] = await db
.select()
.from(protocolExternalMeetingsTable)
.where(eq(protocolExternalMeetingsTable.id, id));
if (!existing) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
// State-conditional claim: only a pending meeting flips to approved.
const [row] = await db
.update(protocolExternalMeetingsTable)
.set({
status: "approved",
approvedBy: req.session.userId!,
approvedAt: new Date(),
rejectionReason: null,
})
.where(
and(
eq(protocolExternalMeetingsTable.id, id),
eq(protocolExternalMeetingsTable.status, "pending"),
),
)
.returning();
if (!row) {
res
.status(409)
.json({ error: "meeting is not pending", code: "invalid_status" });
return;
}
await logAudit(db, {
action: "external_meeting.approve",
entityType: "external_meeting",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: req.session.userId!,
});
res.json(row);
},
);
router.post(
"/protocol/external-meetings/:id/reject",
requireApprove,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
const body = parseBody(res, rejectSchema, req.body);
if (!body) return;
const [existing] = await db
.select()
.from(protocolExternalMeetingsTable)
.where(eq(protocolExternalMeetingsTable.id, id));
if (!existing) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const [row] = await db
.update(protocolExternalMeetingsTable)
.set({
status: "rejected",
approvedBy: req.session.userId!,
approvedAt: new Date(),
rejectionReason: body.reason ?? null,
})
.where(
and(
eq(protocolExternalMeetingsTable.id, id),
eq(protocolExternalMeetingsTable.status, "pending"),
),
)
.returning();
if (!row) {
res
.status(409)
.json({ error: "meeting is not pending", code: "invalid_status" });
return;
}
await logAudit(db, {
action: "external_meeting.reject",
entityType: "external_meeting",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: req.session.userId!,
});
res.json(row);
},
);
// ---------------------------------------------------------------------------
// Gifts & shields catalogue
// ---------------------------------------------------------------------------
@@ -1234,6 +1344,8 @@ router.post(
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
// Only a pending issue can be rejected; the state-conditional update
// makes this atomic against a concurrent approve.
const [row] = await db
.update(protocolGiftIssuesTable)
.set({
@@ -1242,8 +1354,19 @@ router.post(
approvedAt: new Date(),
rejectionReason: body.reason ?? null,
})
.where(eq(protocolGiftIssuesTable.id, id))
.where(
and(
eq(protocolGiftIssuesTable.id, id),
eq(protocolGiftIssuesTable.status, "pending"),
),
)
.returning();
if (!row) {
res
.status(409)
.json({ error: "issue is not pending", code: "invalid_status" });
return;
}
await logAudit(db, {
action: "gift_issue.reject",
entityType: "gift_issue",
@@ -1297,6 +1420,8 @@ router.get(
now.getDate(),
);
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const [roomCount] = await db
.select({ c: sql<number>`count(*)::int` })
@@ -1324,7 +1449,7 @@ router.get(
.from(protocolExternalMeetingsTable)
.where(
and(
eq(protocolExternalMeetingsTable.status, "scheduled"),
inArray(protocolExternalMeetingsTable.status, ["pending", "approved"]),
gte(protocolExternalMeetingsTable.startsAt, now),
),
);
@@ -1342,14 +1467,50 @@ router.get(
.from(protocolGiftIssuesTable)
.where(eq(protocolGiftIssuesTable.status, "pending"));
// Gifts/shields actually issued during the current calendar month.
const [issuedThisMonth] = await db
.select({
count: sql<number>`count(*)::int`,
qty: sql<number>`coalesce(sum(${protocolGiftIssuesTable.quantity}), 0)::int`,
})
.from(protocolGiftIssuesTable)
.where(
and(
eq(protocolGiftIssuesTable.status, "issued"),
gte(protocolGiftIssuesTable.issuedAt, startOfMonth),
lt(protocolGiftIssuesTable.issuedAt, startOfNextMonth),
),
);
// Rooms free right now = active rooms with no pending/approved booking
// currently spanning [startsAt, endsAt).
const [availableNow] = await db
.select({ c: sql<number>`count(*)::int` })
.from(protocolRoomsTable)
.where(
and(
eq(protocolRoomsTable.isActive, true),
sql`not exists (
select 1 from ${protocolRoomBookingsTable} b
where b.room_id = ${protocolRoomsTable.id}
and b.status in ('pending', 'approved')
and b.starts_at <= ${now.toISOString()}
and b.ends_at > ${now.toISOString()}
)`,
),
);
res.json({
activeRooms: roomCount?.c ?? 0,
roomsAvailableNow: availableNow?.c ?? 0,
pendingBookings: pendingBookings?.c ?? 0,
todayBookings: todayBookings?.c ?? 0,
upcomingExternalMeetings: upcomingExternal?.c ?? 0,
giftCatalogCount: giftStock?.kinds ?? 0,
totalGiftStock: giftStock?.totalStock ?? 0,
pendingGiftIssues: pendingIssues?.c ?? 0,
giftsIssuedThisMonth: issuedThisMonth?.count ?? 0,
giftsIssuedThisMonthQty: issuedThisMonth?.qty ?? 0,
});
},
);
@@ -1429,7 +1590,35 @@ router.get(
)
.orderBy(desc(sql`count(*)`));
res.json({ bookingsByRoom, issuesByGift });
const externalRange = [];
if (from)
externalRange.push(gte(protocolExternalMeetingsTable.startsAt, from));
if (to) externalRange.push(lte(protocolExternalMeetingsTable.startsAt, to));
const [externalMeetings] = await db
.select({
total: sql<number>`count(*)::int`,
pending: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'pending')::int`,
approved: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'approved')::int`,
rejected: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'rejected')::int`,
completed: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'completed')::int`,
cancelled: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'cancelled')::int`,
})
.from(protocolExternalMeetingsTable)
.where(externalRange.length > 0 ? and(...externalRange) : undefined);
res.json({
bookingsByRoom,
issuesByGift,
externalMeetings: externalMeetings ?? {
total: 0,
pending: 0,
approved: 0,
rejected: 0,
completed: 0,
cancelled: 0,
},
});
},
);