2026-07-06 09:25:40 +00:00
|
|
|
import {
|
|
|
|
|
pgTable,
|
|
|
|
|
serial,
|
|
|
|
|
integer,
|
|
|
|
|
varchar,
|
|
|
|
|
text,
|
|
|
|
|
timestamp,
|
|
|
|
|
jsonb,
|
|
|
|
|
boolean,
|
|
|
|
|
index,
|
|
|
|
|
} from "drizzle-orm/pg-core";
|
|
|
|
|
import { createInsertSchema } from "drizzle-zod";
|
|
|
|
|
import { z } from "zod/v4";
|
|
|
|
|
import { usersTable } from "./users";
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Public Relations & Protocol module ("العلاقات العامة والمراسم").
|
|
|
|
|
//
|
|
|
|
|
// A completely standalone module. Every table is prefixed `protocol_` and has
|
|
|
|
|
// no foreign keys into the Executive Meetings tables, so the two modules stay
|
|
|
|
|
// fully independent. Only `usersTable` (the shared account table) is
|
|
|
|
|
// referenced, and always with `on delete set null` so removing a user never
|
|
|
|
|
// cascades into protocol history.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// Status values for room bookings and gift issuances. The overlap /
|
|
|
|
|
// conflict-prevention rule only considers "pending" and "approved" rows as
|
|
|
|
|
// occupying a room; "rejected" and "cancelled" free the slot again.
|
|
|
|
|
export const PROTOCOL_BOOKING_STATUSES = [
|
|
|
|
|
"pending",
|
|
|
|
|
"approved",
|
|
|
|
|
"rejected",
|
|
|
|
|
"cancelled",
|
|
|
|
|
] as const;
|
|
|
|
|
export type ProtocolBookingStatus =
|
|
|
|
|
(typeof PROTOCOL_BOOKING_STATUSES)[number];
|
|
|
|
|
|
|
|
|
|
export const PROTOCOL_ISSUE_STATUSES = [
|
|
|
|
|
"pending",
|
|
|
|
|
"approved",
|
|
|
|
|
"rejected",
|
|
|
|
|
"issued",
|
|
|
|
|
] as const;
|
|
|
|
|
export type ProtocolIssueStatus = (typeof PROTOCOL_ISSUE_STATUSES)[number];
|
|
|
|
|
|
|
|
|
|
// Gift catalogue kinds: a regular gift or a commemorative shield (درع).
|
|
|
|
|
export const PROTOCOL_GIFT_KINDS = ["gift", "shield"] as const;
|
|
|
|
|
export type ProtocolGiftKind = (typeof PROTOCOL_GIFT_KINDS)[number];
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Rooms
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
export const protocolRoomsTable = pgTable(
|
|
|
|
|
"protocol_rooms",
|
|
|
|
|
{
|
|
|
|
|
id: serial("id").primaryKey(),
|
|
|
|
|
nameAr: varchar("name_ar", { length: 200 }).notNull(),
|
|
|
|
|
nameEn: varchar("name_en", { length: 200 }).notNull().default(""),
|
|
|
|
|
capacity: integer("capacity"),
|
|
|
|
|
location: varchar("location", { length: 300 }),
|
|
|
|
|
isActive: boolean("is_active").notNull().default(true),
|
|
|
|
|
sortOrder: integer("sort_order").notNull().default(0),
|
|
|
|
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
|
|
|
|
onDelete: "set null",
|
|
|
|
|
}),
|
|
|
|
|
createdAt: timestamp("created_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow(),
|
|
|
|
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow()
|
|
|
|
|
.$onUpdate(() => new Date()),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
activeIdx: index("protocol_rooms_active_idx").on(t.isActive),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Room bookings
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
export const protocolRoomBookingsTable = pgTable(
|
|
|
|
|
"protocol_room_bookings",
|
|
|
|
|
{
|
|
|
|
|
id: serial("id").primaryKey(),
|
|
|
|
|
roomId: integer("room_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => protocolRoomsTable.id, { onDelete: "cascade" }),
|
|
|
|
|
title: varchar("title", { length: 500 }).notNull(),
|
|
|
|
|
requesterName: varchar("requester_name", { length: 300 }),
|
2026-07-06 11:18:14 +00:00
|
|
|
// Guest-supplied contact phone. Only populated for public
|
|
|
|
|
// (self-service) booking requests; internal bookings leave it null.
|
|
|
|
|
requesterPhone: varchar("requester_phone", { length: 40 }),
|
|
|
|
|
// The requester's organization / entity (الجهة). Optional even for
|
|
|
|
|
// public requests; always null for internal bookings unless set.
|
|
|
|
|
requesterOrg: varchar("requester_org", { length: 300 }),
|
2026-07-06 11:33:58 +00:00
|
|
|
// Expected number of attendees (عدد الحضور). Optional; supplied by the
|
|
|
|
|
// guest on a public request or by staff on an internal booking.
|
|
|
|
|
attendeeCount: integer("attendee_count"),
|
2026-07-06 11:18:14 +00:00
|
|
|
// Provenance marker. "internal" for bookings created by authenticated
|
|
|
|
|
// protocol staff through the app; "public" for guest requests coming in
|
|
|
|
|
// via the no-login booking-request link. Defaults to "internal" so all
|
|
|
|
|
// pre-existing rows are treated as internal.
|
|
|
|
|
source: varchar("source", { length: 20 }).notNull().default("internal"),
|
2026-07-06 09:25:40 +00:00
|
|
|
// Full timestamps so bookings can span any part of a day. The overlap
|
|
|
|
|
// rule compares [startsAt, endsAt) intervals per room.
|
|
|
|
|
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
|
|
|
|
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
|
|
|
|
|
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
|
|
|
|
notes: text("notes"),
|
|
|
|
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
|
|
|
|
onDelete: "set null",
|
|
|
|
|
}),
|
|
|
|
|
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
|
|
|
|
onDelete: "set null",
|
|
|
|
|
}),
|
|
|
|
|
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
|
|
|
|
rejectionReason: text("rejection_reason"),
|
|
|
|
|
createdAt: timestamp("created_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow(),
|
|
|
|
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow()
|
|
|
|
|
.$onUpdate(() => new Date()),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
roomIdx: index("protocol_bookings_room_idx").on(t.roomId),
|
|
|
|
|
startIdx: index("protocol_bookings_start_idx").on(t.startsAt),
|
|
|
|
|
statusIdx: index("protocol_bookings_status_idx").on(t.status),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// External protocol meetings (اجتماعات ومراسم خارجية) — independent from the
|
|
|
|
|
// Executive Meetings module's is_external flag.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
export const protocolExternalMeetingsTable = pgTable(
|
|
|
|
|
"protocol_external_meetings",
|
|
|
|
|
{
|
|
|
|
|
id: serial("id").primaryKey(),
|
|
|
|
|
title: varchar("title", { length: 500 }).notNull(),
|
|
|
|
|
// The visiting party / external delegation.
|
|
|
|
|
partyName: varchar("party_name", { length: 300 }),
|
|
|
|
|
location: varchar("location", { length: 300 }),
|
|
|
|
|
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
|
|
|
|
endsAt: timestamp("ends_at", { withTimezone: true }),
|
2026-07-06 09:46:12 +00:00
|
|
|
// Lifecycle: pending -> approved | rejected, then optionally
|
|
|
|
|
// completed | cancelled. Mirrors the booking / gift-issue approval flow.
|
|
|
|
|
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
2026-07-06 09:25:40 +00:00
|
|
|
notes: text("notes"),
|
|
|
|
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
|
|
|
|
onDelete: "set null",
|
|
|
|
|
}),
|
2026-07-06 09:46:12 +00:00
|
|
|
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
|
|
|
|
onDelete: "set null",
|
|
|
|
|
}),
|
|
|
|
|
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
|
|
|
|
rejectionReason: text("rejection_reason"),
|
2026-07-06 09:25:40 +00:00
|
|
|
createdAt: timestamp("created_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow(),
|
|
|
|
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow()
|
|
|
|
|
.$onUpdate(() => new Date()),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
startIdx: index("protocol_ext_meetings_start_idx").on(t.startsAt),
|
2026-07-06 09:46:12 +00:00
|
|
|
statusIdx: index("protocol_ext_meetings_status_idx").on(t.status),
|
2026-07-06 09:25:40 +00:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Gifts & shields catalogue
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
export const protocolGiftsTable = pgTable(
|
|
|
|
|
"protocol_gifts",
|
|
|
|
|
{
|
|
|
|
|
id: serial("id").primaryKey(),
|
|
|
|
|
nameAr: varchar("name_ar", { length: 300 }).notNull(),
|
|
|
|
|
nameEn: varchar("name_en", { length: 300 }).notNull().default(""),
|
|
|
|
|
kind: varchar("kind", { length: 20 }).notNull().default("gift"),
|
|
|
|
|
descriptionAr: text("description_ar"),
|
|
|
|
|
descriptionEn: text("description_en"),
|
|
|
|
|
imageUrl: varchar("image_url", { length: 500 }),
|
|
|
|
|
// Running stock count. Issuing an approved gift decrements this.
|
|
|
|
|
quantityInStock: integer("quantity_in_stock").notNull().default(0),
|
|
|
|
|
isActive: boolean("is_active").notNull().default(true),
|
|
|
|
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
|
|
|
|
onDelete: "set null",
|
|
|
|
|
}),
|
|
|
|
|
createdAt: timestamp("created_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow(),
|
|
|
|
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow()
|
|
|
|
|
.$onUpdate(() => new Date()),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
kindIdx: index("protocol_gifts_kind_idx").on(t.kind),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Gift issuances (صرف الهدايا/الدروع)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
export const protocolGiftIssuesTable = pgTable(
|
|
|
|
|
"protocol_gift_issues",
|
|
|
|
|
{
|
|
|
|
|
id: serial("id").primaryKey(),
|
|
|
|
|
giftId: integer("gift_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => protocolGiftsTable.id, { onDelete: "restrict" }),
|
|
|
|
|
recipientName: varchar("recipient_name", { length: 300 }).notNull(),
|
|
|
|
|
occasion: varchar("occasion", { length: 300 }),
|
|
|
|
|
quantity: integer("quantity").notNull().default(1),
|
|
|
|
|
issuedAt: timestamp("issued_at", { withTimezone: true }),
|
|
|
|
|
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
|
|
|
|
notes: text("notes"),
|
|
|
|
|
createdBy: integer("created_by").references(() => usersTable.id, {
|
|
|
|
|
onDelete: "set null",
|
|
|
|
|
}),
|
|
|
|
|
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
|
|
|
|
onDelete: "set null",
|
|
|
|
|
}),
|
|
|
|
|
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
|
|
|
|
rejectionReason: text("rejection_reason"),
|
|
|
|
|
createdAt: timestamp("created_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow(),
|
|
|
|
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow()
|
|
|
|
|
.$onUpdate(() => new Date()),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
giftIdx: index("protocol_gift_issues_gift_idx").on(t.giftId),
|
|
|
|
|
statusIdx: index("protocol_gift_issues_status_idx").on(t.status),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Audit log (scoped to the protocol module only)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
export const protocolAuditLogsTable = pgTable(
|
|
|
|
|
"protocol_audit_logs",
|
|
|
|
|
{
|
|
|
|
|
id: serial("id").primaryKey(),
|
|
|
|
|
action: varchar("action", { length: 100 }).notNull(),
|
|
|
|
|
entityType: varchar("entity_type", { length: 50 }).notNull(),
|
|
|
|
|
entityId: integer("entity_id"),
|
|
|
|
|
oldValue: jsonb("old_value"),
|
|
|
|
|
newValue: jsonb("new_value"),
|
|
|
|
|
performedBy: integer("performed_by").references(() => usersTable.id, {
|
|
|
|
|
onDelete: "set null",
|
|
|
|
|
}),
|
|
|
|
|
performedAt: timestamp("performed_at", { withTimezone: true })
|
|
|
|
|
.notNull()
|
|
|
|
|
.defaultNow(),
|
|
|
|
|
},
|
|
|
|
|
(t) => ({
|
|
|
|
|
entityIdx: index("protocol_audit_entity_idx").on(t.entityType, t.entityId),
|
|
|
|
|
performedAtIdx: index("protocol_audit_performed_at_idx").on(t.performedAt),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Insert schemas / row types
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
export const insertProtocolRoomSchema = createInsertSchema(
|
|
|
|
|
protocolRoomsTable,
|
|
|
|
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
|
|
|
|
export type InsertProtocolRoom = z.infer<typeof insertProtocolRoomSchema>;
|
|
|
|
|
export type ProtocolRoom = typeof protocolRoomsTable.$inferSelect;
|
|
|
|
|
|
|
|
|
|
export const insertProtocolRoomBookingSchema = createInsertSchema(
|
|
|
|
|
protocolRoomBookingsTable,
|
|
|
|
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
|
|
|
|
export type InsertProtocolRoomBooking = z.infer<
|
|
|
|
|
typeof insertProtocolRoomBookingSchema
|
|
|
|
|
>;
|
|
|
|
|
export type ProtocolRoomBooking = typeof protocolRoomBookingsTable.$inferSelect;
|
|
|
|
|
|
|
|
|
|
export const insertProtocolExternalMeetingSchema = createInsertSchema(
|
|
|
|
|
protocolExternalMeetingsTable,
|
|
|
|
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
|
|
|
|
export type InsertProtocolExternalMeeting = z.infer<
|
|
|
|
|
typeof insertProtocolExternalMeetingSchema
|
|
|
|
|
>;
|
|
|
|
|
export type ProtocolExternalMeeting =
|
|
|
|
|
typeof protocolExternalMeetingsTable.$inferSelect;
|
|
|
|
|
|
|
|
|
|
export const insertProtocolGiftSchema = createInsertSchema(
|
|
|
|
|
protocolGiftsTable,
|
|
|
|
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
|
|
|
|
export type InsertProtocolGift = z.infer<typeof insertProtocolGiftSchema>;
|
|
|
|
|
export type ProtocolGift = typeof protocolGiftsTable.$inferSelect;
|
|
|
|
|
|
|
|
|
|
export const insertProtocolGiftIssueSchema = createInsertSchema(
|
|
|
|
|
protocolGiftIssuesTable,
|
|
|
|
|
).omit({ id: true, createdAt: true, updatedAt: true });
|
|
|
|
|
export type InsertProtocolGiftIssue = z.infer<
|
|
|
|
|
typeof insertProtocolGiftIssueSchema
|
|
|
|
|
>;
|
|
|
|
|
export type ProtocolGiftIssue = typeof protocolGiftIssuesTable.$inferSelect;
|
|
|
|
|
|
|
|
|
|
export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect;
|