Files
TX/executive-meetings-schema.ts.old
T
Riyadh b228b7d652 Task #349: Executive Meetings PDF improvements
- Schema: add `font_color` (hex, default #000000) and `logo_object_path`
  to `executive_meeting_font_settings`. Pushed via drizzle-kit.

- PDF renderer:
  - Add `fontColor` to PdfFontPrefs (body cells only; header chrome
    and logo intentionally ignore it to preserve branding).
  - Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in
    lockstep with the on-screen swatches; deliberately stop painting
    the legacy `isHighlighted` overlay so the archived PDF reflects
    editorial state instead of the viewer's transient cursor.
  - Add optional `logo: Buffer`. Header now reserves a left-anchored
    logo box and centers the title in the remaining width; bad image
    bytes log + fall back to the no-logo layout instead of crashing.

- API route:
  - Extend fontSettingsSchema with strict #RRGGBB regex and
    /^/objects/<id>$/ regex for logoObjectPath.
  - resolveFontPrefsForUser now returns { font, logoObjectPath }.
  - loadLogoBytes downloads the brand asset via ObjectStorageService.
  - Logo only writable on the global-scope row.
  - PDF labels switched to "قائمة بأسماء حضور الاجتماعات" /
    "Meeting Attendance List" per the user's printed sample.

- Frontend (executive-meetings.tsx):
  - FontPrefs gains fontColor; FontSettingsResponse.global gains
    logoObjectPath; DEFAULT_FONT and effectiveFont updated.
  - buildFontStyle applies fontColor to on-screen rows.
  - FontSettingsSection: native color picker + hex text input;
    logo upload (PNG/JPEG) via @workspace/object-storage-web's
    useUpload, visible only at the global scope for admins.

- Locales: AR/EN keys for fontColor + logo.{label,upload,replace,
  remove,uploading,uploadFailed,globalOnly}.

- Tests: existing font-settings roundtrip extended with fontColor;
  new test rejects malformed fontColor and non-/objects logo paths.

Type-check clean for api-server and tx-os; new font-settings tests
pass. Other test failures in the suite pre-date this change.
2026-05-03 14:34:29 +00:00

286 lines
12 KiB
Plaintext

import {
pgTable,
serial,
integer,
varchar,
text,
timestamp,
date,
time,
jsonb,
boolean,
index,
uniqueIndex,
check,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { usersTable } from "./users";
// Single source of truth for the row-highlight palette used by the
// daily schedule overlay (#288). Imported by the API route's Zod
// whitelist AND pinned at the database layer via the CHECK constraint
// below (#293), so the same invariant survives any out-of-band write
// path (manual SQL, future bulk jobs, restored backups). NULL means
// "no tint" / default; any other value must be one of these keys.
export const EXECUTIVE_MEETING_ROW_COLOR_KEYS = [
"red",
"amber",
"green",
"blue",
"violet",
"gray",
] as const;
export type ExecutiveMeetingRowColor =
(typeof EXECUTIVE_MEETING_ROW_COLOR_KEYS)[number];
export const executiveMeetingsTable = pgTable(
"executive_meetings",
{
id: serial("id").primaryKey(),
dailyNumber: integer("daily_number").notNull(),
// titleAr/En hold sanitized rich-text HTML (Tiptap output) so they need
// unbounded length. Plain-text rows from before the widening still
// render correctly because they contain no HTML tags.
titleAr: text("title_ar").notNull(),
titleEn: text("title_en").notNull().default(""),
meetingDate: date("meeting_date").notNull(),
startTime: time("start_time", { withTimezone: false }),
endTime: time("end_time", { withTimezone: false }),
location: varchar("location", { length: 300 }),
meetingUrl: text("meeting_url"),
platform: varchar("platform", { length: 32 }).notNull().default("none"),
status: varchar("status", { length: 32 }).notNull().default("scheduled"),
isHighlighted: integer("is_highlighted").notNull().default(0),
notes: text("notes"),
attachments: jsonb("attachments").default([]),
// Optional cell-merge overlay for the schedule grid. When set, the
// schedule renders one merged cell spanning columns
// [mergeStartColumn..mergeEndColumn] and shows mergeText (sanitized
// HTML) instead of the original column values. The original
// titleAr/En + attendees + start/endTime stay untouched in the DB,
// so clearing the merge restores the original cells exactly.
// Allowed values for the column ids match the frontend ColumnId
// enum: "number" | "meeting" | "attendees" | "time".
mergeStartColumn: varchar("merge_start_column", { length: 32 }),
mergeEndColumn: varchar("merge_end_column", { length: 32 }),
mergeText: text("merge_text"),
// Optional row-highlight colour for the daily schedule grid. NULL =
// "default" (no tint). Allowed non-null values come from the
// ROW_COLOR_OPTIONS palette in the frontend (red, amber, green,
// blue, violet, gray) and are validated app-side. Stored on the
// meeting row itself (not per-user) so the colour is shared across
// every viewer in real time, since it carries an editorial
// signal about the meeting (urgent / VIP / etc.) rather than a
// personal viewing preference.
rowColor: varchar("row_color", { length: 16 }),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
updatedBy: integer("updated_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) => ({
dateIdx: index("executive_meetings_date_idx").on(t.meetingDate),
dateNumberUnique: uniqueIndex("executive_meetings_date_number_unique").on(
t.meetingDate,
t.dailyNumber,
),
// Defense-in-depth (#293): mirror the API-side ROW_COLOR_KEYS
// whitelist at the DB layer so any out-of-band write path
// (manual SQL, future bulk jobs, restored backups) cannot smuggle
// an unrenderable colour past the Zod guard. NULL is the "no tint"
// sentinel, the six keys are the supported palette. Any other
// value raises a check_violation (PG SQLSTATE 23514) that the
// caller must explicitly handle.
// Literal-SQL form: Postgres CHECK constraint definitions are
// parsed as DDL and reject parameter placeholders ($1, $2, …),
// so the palette keys are inlined as quoted literals via
// `sql.raw`. This is safe because the keys come from a hardcoded
// compile-time constant of single-word identifiers (no user
// input ever reaches this string).
rowColorPaletteCheck: check(
"executive_meetings_row_color_palette_check",
sql`${t.rowColor} IS NULL OR ${t.rowColor} IN (${sql.raw(
EXECUTIVE_MEETING_ROW_COLOR_KEYS.map((k) => `'${k}'`).join(", "),
)})`,
),
}),
);
export const executiveMeetingAttendeesTable = pgTable(
"executive_meeting_attendees",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
// attendee name holds sanitized rich-text HTML so it needs unbounded
// length. Plain-text rows still render correctly (no tags = no parse).
// For `kind = "subheading"` rows, this column stores the user-written
// section label (free text, single value rendered as-is in AR and EN).
name: text("name").notNull(),
title: varchar("title", { length: 200 }),
attendanceType: varchar("attendance_type", { length: 32 })
.notNull()
.default("internal"),
sortOrder: integer("sort_order").notNull().default(0),
// "person" — a real attendee row (default; matches all pre-existing
// data after backfill).
// "subheading" — a user-defined section label inserted between attendees
// inside the same attendance-type group. Numbering, count,
// and virtual-platform extraction MUST ignore these rows.
kind: varchar("kind", { length: 16 }).notNull().default("person"),
},
(t) => ({
meetingIdx: index("executive_meeting_attendees_meeting_idx").on(t.meetingId),
}),
);
export const executiveMeetingNotificationsTable = pgTable(
"executive_meeting_notifications",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id").references(() => executiveMeetingsTable.id, {
onDelete: "cascade",
}),
userId: integer("user_id").references(() => usersTable.id, { onDelete: "cascade" }),
notificationType: varchar("notification_type", { length: 64 }).notNull(),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
status: varchar("status", { length: 32 }).notNull().default("pending"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
);
/**
* Per-user notification preferences for the Executive Meetings module.
*
* Each row toggles a single (userId, notificationType) pair on or off
* for the in-app bell channel and the email channel independently.
*
* Default behaviour when no row exists: BOTH channels ON. This keeps the
* legacy "fan out to everyone, every time" semantics intact for users
* who never visit the preferences UI. Only an explicit opt-out (row
* present + flag set to false) suppresses delivery.
*
* notificationType mirrors the keys passed to
* `recordExecutiveMeetingNotifications` / `sendExecutiveMeetingEmail`:
* - meeting_created
*/
export const executiveMeetingNotificationPrefsTable = pgTable(
"executive_meeting_notification_prefs",
{
id: serial("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
notificationType: varchar("notification_type", { length: 64 }).notNull(),
inApp: boolean("in_app").notNull().default(true),
email: boolean("email").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
userTypeUnique: uniqueIndex(
"executive_meeting_notification_prefs_user_type_unique",
).on(t.userId, t.notificationType),
}),
);
export const executiveMeetingAuditLogsTable = pgTable(
"executive_meeting_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(),
},
);
export const executiveMeetingPdfArchivesTable = pgTable(
"executive_meeting_pdf_archives",
{
id: serial("id").primaryKey(),
archiveDate: date("archive_date").notNull(),
// `filePath` doubles as the storage URL for the archived PDF. When the
// PDF was uploaded to object storage the value is a `/objects/<id>`
// path that resolves through GET /api/storage/objects/*. Older rows
// (browser-print snapshots) use the synthetic `print:<date>` form.
filePath: text("file_path").notNull(),
version: integer("version").notNull().default(1),
// Bytes of the generated PDF on disk. Nullable because legacy rows
// (created before the server-side generator) never had a real file.
byteSize: integer("byte_size"),
generatedBy: integer("generated_by").references(() => usersTable.id, {
onDelete: "set null",
}),
generatedAt: timestamp("generated_at", { withTimezone: true }).notNull().defaultNow(),
},
);
// #273: Per-user dismissal/acknowledgment state for the 5-minute
// upcoming-meeting alert. One row per (meetingId, userId). Absence of
// a row means the user has neither acknowledged ("Done") nor dismissed
// ("Delete alert") the alert for that meeting yet, so the alert is
// eligible to fire when the meeting falls inside the 5-minute window.
export const executiveMeetingAlertStateTable = pgTable(
"executive_meeting_alert_state",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
dismissed: boolean("dismissed").notNull().default(false),
acknowledged: boolean("acknowledged").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
perUserUnique: uniqueIndex(
"executive_meeting_alert_state_meeting_user_unique",
).on(t.meetingId, t.userId),
}),
);
export const executiveMeetingFontSettingsTable = pgTable(
"executive_meeting_font_settings",
{
id: serial("id").primaryKey(),
scope: varchar("scope", { length: 16 }).notNull().default("global"),
userId: integer("user_id").references(() => usersTable.id, { onDelete: "cascade" }),
fontFamily: varchar("font_family", { length: 100 }).notNull().default("system"),
fontSize: integer("font_size").notNull().default(14),
fontWeight: varchar("font_weight", { length: 16 }).notNull().default("regular"),
alignment: varchar("alignment", { length: 16 }).notNull().default("start"),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
);
export type ExecutiveMeeting = typeof executiveMeetingsTable.$inferSelect;
export type ExecutiveMeetingAttendee = typeof executiveMeetingAttendeesTable.$inferSelect;