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