diff --git a/src/lib/db/migrations/0084_reports_page.sql b/src/lib/db/migrations/0084_reports_page.sql new file mode 100644 index 00000000..4052971d --- /dev/null +++ b/src/lib/db/migrations/0084_reports_page.sql @@ -0,0 +1,82 @@ +-- Reports page foundation. Part of the locked /reports page design +-- (docs/reports-page-design.md). Three pieces: +-- 1. Extend the existing report_templates with visibility + archive flag. +-- 2. Append-only report_runs audit log. +-- 3. report_schedules driving the BullMQ recurring scheduler. + +-- ─── 1. Extend report_templates ───────────────────────────────────────────── +ALTER TABLE report_templates + ADD COLUMN IF NOT EXISTS description text, + ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'private', + ADD COLUMN IF NOT EXISTS archived_at timestamptz; + +CREATE INDEX IF NOT EXISTS idx_report_templates_port_visibility + ON report_templates(port_id, visibility); + +-- Soften the unique-name constraint so archived templates don't block reuse +-- of a name. Drop the existing index + recreate with a WHERE clause. +DROP INDEX IF EXISTS uniq_report_templates_port_kind_name; +CREATE UNIQUE INDEX uniq_report_templates_port_kind_name + ON report_templates(port_id, kind, LOWER(name)) + WHERE archived_at IS NULL; + +-- ─── 2. report_runs (append-only audit log) ───────────────────────────────── +CREATE TABLE IF NOT EXISTS report_runs ( + id text PRIMARY KEY DEFAULT gen_random_uuid()::text, + port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE, + -- Nullable: ad-hoc runs (no template) still get logged. + template_id text REFERENCES report_templates(id) ON DELETE SET NULL, + -- Forward-declared FK; populated once report_schedules is created below. + schedule_id text, + kind text NOT NULL, + -- Snapshotted at run time so re-runs reproduce identically even if the + -- source template has since been edited / archived. + config jsonb NOT NULL, + output_format text NOT NULL, -- 'pdf' | 'csv' | 'png' | 'jpg' + -- Storage key of the rendered artefact. Same backend as files (s3 or fs). + storage_key text, + size_bytes integer, + status text NOT NULL DEFAULT 'pending', -- pending | rendering | complete | failed + error_message text, + triggered_by text NOT NULL, -- 'user' | 'schedule' + triggered_by_user_id text REFERENCES "user"(id) ON DELETE SET NULL, + -- When non-null, this run was emailed to these recipients on completion. + emailed_to jsonb, -- Array<{ name?: string, email: string }> + emailed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz +); + +CREATE INDEX IF NOT EXISTS report_runs_port_created_idx + ON report_runs(port_id, created_at DESC); +CREATE INDEX IF NOT EXISTS report_runs_port_user_idx + ON report_runs(port_id, triggered_by_user_id); +CREATE INDEX IF NOT EXISTS report_runs_port_template_idx + ON report_runs(port_id, template_id) + WHERE template_id IS NOT NULL; + +-- ─── 3. report_schedules (BullMQ recurring scheduler) ──────────────────────── +CREATE TABLE IF NOT EXISTS report_schedules ( + id text PRIMARY KEY DEFAULT gen_random_uuid()::text, + port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE, + template_id text NOT NULL REFERENCES report_templates(id) ON DELETE CASCADE, + -- Enum cadence at v1; cron strings can layer on top later. + cadence text NOT NULL, + recipients jsonb NOT NULL, -- Array<{ name?: string, email: string }> + output_format text NOT NULL DEFAULT 'pdf', + enabled boolean NOT NULL DEFAULT true, + last_run_at timestamptz, + next_run_at timestamptz NOT NULL, + created_by text NOT NULL REFERENCES "user"(id) ON DELETE RESTRICT, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS report_schedules_port_enabled_next_idx + ON report_schedules(port_id, enabled, next_run_at); + +-- Now wire the forward-declared schedule_id FK from report_runs back to the +-- schedules table. +ALTER TABLE report_runs + ADD CONSTRAINT report_runs_schedule_id_fkey + FOREIGN KEY (schedule_id) REFERENCES report_schedules(id) ON DELETE SET NULL; diff --git a/src/lib/db/schema/reports.ts b/src/lib/db/schema/reports.ts index b5067416..c78e6369 100644 --- a/src/lib/db/schema/reports.ts +++ b/src/lib/db/schema/reports.ts @@ -1,7 +1,17 @@ -import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; import { ports } from './ports'; +import { user } from './users'; /** * Saved report templates. Each row captures a named, port-scoped @@ -31,6 +41,11 @@ export const reportTemplates = pgTable( * via the same zod schemas they use on `/api/v1/reports/generate`. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any config: jsonb('config').$type>().notNull(), + /** 'private' = creator only; 'team' = anyone with reports.export on + * this port. Locked decision: skip "shared with specific users" + * beyond port-wide team for v1. */ + visibility: text('visibility').notNull().default('private'), + archivedAt: timestamp('archived_at', { withTimezone: true }), createdBy: text('created_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), @@ -38,9 +53,11 @@ export const reportTemplates = pgTable( (table) => [ index('idx_report_templates_port').on(table.portId), index('idx_report_templates_port_kind').on(table.portId, table.kind), + index('idx_report_templates_port_visibility').on(table.portId, table.visibility), // Sibling-name uniqueness per port+kind. The lower() expression in // the SQL migration mirrors how case-insensitive collisions are - // handled elsewhere in the schema. + // handled elsewhere in the schema. Migration 0084 adds a WHERE clause + // to allow archived templates to free up their names. uniqueIndex('uniq_report_templates_port_kind_name').on( table.portId, table.kind, @@ -51,3 +68,97 @@ export const reportTemplates = pgTable( export type ReportTemplate = typeof reportTemplates.$inferSelect; export type NewReportTemplate = typeof reportTemplates.$inferInsert; + +// ─── report_schedules ────────────────────────────────────────────────────── +/** + * Recurring schedule that re-runs a saved template on a fixed cadence + emails + * the resulting artefact to a defined recipient list. Pre-computes + * `next_run_at` so the BullMQ scheduler can poll `enabled=true AND next_run_at <= now()` + * efficiently. Cadence enum at v1; cron strings can layer later. + * + * Migration: 0084_reports_page.sql. + */ +export const reportSchedules = pgTable( + 'report_schedules', + { + id: text('id') + .primaryKey() + .default(sql`gen_random_uuid()::text`), + portId: text('port_id') + .notNull() + .references(() => ports.id, { onDelete: 'cascade' }), + templateId: text('template_id') + .notNull() + .references(() => reportTemplates.id, { onDelete: 'cascade' }), + /** 'weekly_monday_9' | 'monthly_first_9' | 'quarterly_first_9' for v1. */ + cadence: text('cadence').notNull(), + recipients: jsonb('recipients').$type>().notNull(), + outputFormat: text('output_format').notNull().default('pdf'), + enabled: boolean('enabled').notNull().default(true), + lastRunAt: timestamp('last_run_at', { withTimezone: true }), + nextRunAt: timestamp('next_run_at', { withTimezone: true }).notNull(), + createdBy: text('created_by') + .notNull() + .references(() => user.id, { onDelete: 'restrict' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('report_schedules_port_enabled_next_idx').on( + table.portId, + table.enabled, + table.nextRunAt, + ), + ], +); + +export type ReportSchedule = typeof reportSchedules.$inferSelect; +export type NewReportSchedule = typeof reportSchedules.$inferInsert; + +// ─── report_runs ─────────────────────────────────────────────────────────── +/** + * Append-only audit log of every generated report. Snapshots the config + * used so re-runs reproduce identically even if the source template has + * been edited or archived. + * + * Migration: 0084_reports_page.sql. + */ +export const reportRuns = pgTable( + 'report_runs', + { + id: text('id') + .primaryKey() + .default(sql`gen_random_uuid()::text`), + portId: text('port_id') + .notNull() + .references(() => ports.id, { onDelete: 'cascade' }), + templateId: text('template_id').references(() => reportTemplates.id, { onDelete: 'set null' }), + scheduleId: text('schedule_id').references(() => reportSchedules.id, { onDelete: 'set null' }), + kind: text('kind').notNull(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: jsonb('config').$type>().notNull(), + outputFormat: text('output_format').notNull(), + storageKey: text('storage_key'), + sizeBytes: integer('size_bytes'), + /** 'pending' | 'rendering' | 'complete' | 'failed'. */ + status: text('status').notNull().default('pending'), + errorMessage: text('error_message'), + /** 'user' | 'schedule'. */ + triggeredBy: text('triggered_by').notNull(), + triggeredByUserId: text('triggered_by_user_id').references(() => user.id, { + onDelete: 'set null', + }), + emailedTo: jsonb('emailed_to').$type>(), + emailedAt: timestamp('emailed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + completedAt: timestamp('completed_at', { withTimezone: true }), + }, + (table) => [ + index('report_runs_port_created_idx').on(table.portId, table.createdAt), + index('report_runs_port_user_idx').on(table.portId, table.triggeredByUserId), + index('report_runs_port_template_idx').on(table.portId, table.templateId), + ], +); + +export type ReportRun = typeof reportRuns.$inferSelect; +export type NewReportRun = typeof reportRuns.$inferInsert;