Files
TX/lib/db/scripts/pre-push-cleanup.ts
Replit Agent b38e2f9a90 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.

Deploy safety / backfill:
- lib/db pre-push-cleanup (runs before every drizzle push, dev + deploy)
  now backfills booking_reference for any pre-existing rows
  (RB-{year(created_at)}-{pad4(id)}) BEFORE the NOT NULL + unique
  constraint is enforced, so pushing the constraint on a populated table
  can never fail. Idempotent: only updates NULL rows.

Verification:
- Public booking POST returns a reference; external-without-org rejected
  (400); DB has zero null references after push. Re-running push logs
  "No protocol_room_bookings booking_reference to backfill" (idempotent).
- Pre-existing push.ts / executive-meetings.ts typecheck errors are
  unrelated to this task (files untouched).
2026-07-07 12:38:03 +00:00

139 lines
4.8 KiB
TypeScript

import pg from "pg";
const { Client } = pg;
if (!process.env.DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set. Did you forget to provision a database?",
);
}
async function tableExists(client: pg.Client, table: string): Promise<boolean> {
const result = await client.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`,
[table],
);
return (result.rowCount ?? 0) > 0;
}
async function main(): Promise<void> {
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
await client.query("BEGIN");
if (await tableExists(client, "app_permissions")) {
const dupes = await client.query(
`SELECT
count(*)::int AS dup_groups,
coalesce(sum(c - 1), 0)::int AS extra_rows
FROM (
SELECT app_id, permission_id, count(*) AS c
FROM app_permissions
GROUP BY app_id, permission_id
HAVING count(*) > 1
) d`,
);
const dupGroups = (dupes.rows[0]?.dup_groups ?? 0) as number;
const extraRows = (dupes.rows[0]?.extra_rows ?? 0) as number;
if (dupGroups > 0) {
await client.query(
`CREATE TEMP TABLE app_permissions_dedup ON COMMIT DROP AS
SELECT DISTINCT app_id, permission_id FROM app_permissions`,
);
await client.query(`DELETE FROM app_permissions`);
await client.query(
`INSERT INTO app_permissions (app_id, permission_id)
SELECT app_id, permission_id FROM app_permissions_dedup`,
);
console.log(
`[pre-push] Removed ${extraRows} duplicate app_permissions row(s) across ${dupGroups} (app_id, permission_id) key(s).`,
);
} else {
console.log("[pre-push] app_permissions has no duplicates.");
}
} else {
console.log("[pre-push] app_permissions table not present yet — skipping dedup.");
}
if (
(await tableExists(client, "executive_meeting_notifications")) &&
(await tableExists(client, "executive_meetings"))
) {
const orphan = await client.query(
`DELETE FROM executive_meeting_notifications n
WHERE n.meeting_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM executive_meetings m WHERE m.id = n.meeting_id
)`,
);
const removed = orphan.rowCount ?? 0;
if (removed > 0) {
console.log(
`[pre-push] Removed ${removed} orphan executive_meeting_notifications row(s).`,
);
} else {
console.log("[pre-push] No orphan executive_meeting_notifications rows.");
}
} else {
console.log(
"[pre-push] executive_meeting_notifications/executive_meetings not both present — skipping orphan cleanup.",
);
}
// Backfill booking references BEFORE the schema push enforces the
// NOT NULL + unique constraint on protocol_room_bookings.booking_reference.
// Without this, pushing the constraint against a populated table (any
// pre-existing booking predates the column) would fail. Reference format
// mirrors makeBookingReference() in the API: RB-{year}-{pad4(id)}, which
// is unique because id is unique. Idempotent: only touches NULL rows.
if (await tableExists(client, "protocol_room_bookings")) {
const hasColumn = await client.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'protocol_room_bookings'
AND column_name = 'booking_reference'`,
);
if ((hasColumn.rowCount ?? 0) > 0) {
const backfilled = await client.query(
`UPDATE protocol_room_bookings
SET booking_reference =
'RB-' || EXTRACT(YEAR FROM created_at)::int
|| '-' || LPAD(id::text, 4, '0')
WHERE booking_reference IS NULL`,
);
const filled = backfilled.rowCount ?? 0;
if (filled > 0) {
console.log(
`[pre-push] Backfilled ${filled} protocol_room_bookings booking_reference value(s).`,
);
} else {
console.log(
"[pre-push] No protocol_room_bookings booking_reference to backfill.",
);
}
} else {
console.log(
"[pre-push] protocol_room_bookings.booking_reference column not present yet — skipping backfill.",
);
}
} else {
console.log(
"[pre-push] protocol_room_bookings table not present yet — skipping booking_reference backfill.",
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
throw err;
} finally {
await client.end();
}
}
main().catch((err) => {
console.error("[pre-push] cleanup failed:", err);
process.exit(1);
});