736785e3d3
User asked for three things in the executive-meetings module:
1. Remove the "daily number" input from the add/edit dialog — numbering
is auto-assigned by the server already, so the field was just noise.
2. Add an "اجتماع خارجي" (external meeting) toggle. When ON, the row
is force-tinted red in the schedule grid.
3. External meetings must be excluded from the auto-postpone flow:
the alert "Postpone" button is disabled, and the cascade shift
skips them as followers.
Changes
- lib/db/src/schema/executive-meetings.ts
+ `is_external boolean not null default false` column. Pushed via
drizzle-kit (no destructive migration; defaults backfill).
- artifacts/api-server/src/routes/executive-meetings.ts
+ `isExternal` added to base zod fields, POST insert, PATCH update.
+ POST/PATCH force `rowColor='red'` when `isExternal=true`.
+ POST /:id/postpone-minutes rejects external meetings with
HTTP 409 + code `external_meeting_no_auto_postpone`.
+ `computeCascadeShift` filters out external followers (preview +
writer stay in lockstep, as the comment promises).
- artifacts/tx-os/src/pages/executive-meetings.tsx
+ Meeting type + MeetingFormState gain `isExternal`; dropped the
`dailyNumber` field from the form state entirely.
+ `emptyMeetingForm` / `openEdit` updated; ManageSection.save() and
the inline ScheduleSection save body now send `isExternal` and
never send `dailyNumber`.
+ MeetingFormDialog: removed the daily-number FormRow and added a
Switch-based external-meeting FormRow with localized hint.
+ `rowColors` memo coerces to "red" whenever `m.isExternal`.
+ Audit-diff "interesting" list now includes `isExternal`.
- artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
+ Meeting type gains optional `isExternal`.
+ "Postpone" button disabled + tooltip when meeting is external.
- locales: added `field.isExternal`, `field.isExternalHint`,
`field.isExternalOn/Off`, and `alert.externalCannotPostpone` in
both ar.json and en.json. Existing dailyNumber keys kept (still
referenced by the manage-table column header / cell display).
Post-review hardening (from architect pass)
- PATCH now coerces rowColor → 'red' for any PATCH that touches a row
that is (or becomes) external. Prevents PDF/export paths that read
raw rowColor from showing a non-red tint for externals.
- Schedule row quick-actions popover: the "Postpone" button is now
disabled with a localized tooltip when the meeting is external,
matching the upcoming-meeting alert behavior.
- PostponeDialog.handleErr maps the 409 code
`external_meeting_no_auto_postpone` to the localized
`alert.externalCannotPostpone` toast so users see a real reason
instead of the raw English error string.
Notes
- The `dailyNumber` column stays in DB (heavily used for slot
persistence and the unique index); only the form input was removed.
Server's `nextDailyNumber()` already auto-assigns when the field is
absent on POST, and PATCH leaves it untouched when absent.
- Two pre-existing TS errors remain in api-server (`isHighlighted`
type-inference quirk on `z.union(...).transform(...)` and a
font_settings comparison) — not in scope for this task.
- Tests not added; verification by typecheck on changed surfaces and
workflow-restart smoke. No e2e harness was wired.
302 lines
13 KiB
TypeScript
302 lines
13 KiB
TypeScript
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 }),
|
|
// #635: External meetings (اجتماع خارجي). When true, the row is
|
|
// tinted red in the schedule grid and is excluded from the
|
|
// auto-postpone cascade (the alert "Postpone" button is disabled
|
|
// and `computeCascadeShift` skips it as a follower). Default false
|
|
// preserves legacy rows as "internal" meetings.
|
|
isExternal: boolean("is_external").notNull().default(false),
|
|
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),
|
|
// #558: Set once by the server-side scheduler the moment a Web
|
|
// Push reminder has been fired for this (meeting, user) pair, so a
|
|
// single tick (or any later tick that re-runs the same query) does
|
|
// not double-ring the iPad. NULL = no push has been sent yet.
|
|
pushedAt: timestamp("pushed_at", { withTimezone: true }),
|
|
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 }).default("system"),
|
|
fontSize: integer("font_size").default(14),
|
|
fontWeight: varchar("font_weight", { length: 16 }).default("regular"),
|
|
alignment: varchar("alignment", { length: 16 }).default("start"),
|
|
fontColor: varchar("font_color", { length: 16 }).default("#000000"),
|
|
// Object-storage path (`/objects/<id>`) of the brand logo embedded
|
|
// in the top-left of the generated PDF header. NULL = no logo. Only
|
|
// meaningful on the global-scope row (the per-user row ignores it).
|
|
logoObjectPath: text("logo_object_path"),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
.notNull()
|
|
.defaultNow()
|
|
.$onUpdate(() => new Date()),
|
|
},
|
|
);
|
|
|
|
export type ExecutiveMeeting = typeof executiveMeetingsTable.$inferSelect;
|
|
export type ExecutiveMeetingAttendee = typeof executiveMeetingAttendeesTable.$inferSelect;
|