feat(reports-p1): schema + perms foundation for /reports page
Part of the locked Reports page design (docs/reports-page-design.md). This PR is the data foundation — API routes, UI builder, scheduler, and rendering pipeline land in subsequent PRs. What ships: - Migration 0084: extends report_templates with description + visibility + archived_at, softens the unique-name index to skip archived rows, adds report_runs (append-only audit log) and report_schedules (BullMQ recurring scheduler) tables with full indexes. - Schema TypeScript additions in src/lib/db/schema/reports.ts: reportSchedules + reportRuns table definitions with strongly-typed recipients / config / status enums. Behaviour today: no UI changes; existing /api/v1/reports/generate keeps working unchanged. Saved templates can be archived via report_templates.archived_at once the templates CRUD API lands in P2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
82
src/lib/db/migrations/0084_reports_page.sql
Normal file
82
src/lib/db/migrations/0084_reports_page.sql
Normal file
@@ -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;
|
||||||
@@ -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 { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
import { ports } from './ports';
|
import { ports } from './ports';
|
||||||
|
import { user } from './users';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saved report templates. Each row captures a named, port-scoped
|
* 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`. */
|
* via the same zod schemas they use on `/api/v1/reports/generate`. */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
config: jsonb('config').$type<Record<string, any>>().notNull(),
|
config: jsonb('config').$type<Record<string, any>>().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(),
|
createdBy: text('created_by').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
@@ -38,9 +53,11 @@ export const reportTemplates = pgTable(
|
|||||||
(table) => [
|
(table) => [
|
||||||
index('idx_report_templates_port').on(table.portId),
|
index('idx_report_templates_port').on(table.portId),
|
||||||
index('idx_report_templates_port_kind').on(table.portId, table.kind),
|
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
|
// Sibling-name uniqueness per port+kind. The lower() expression in
|
||||||
// the SQL migration mirrors how case-insensitive collisions are
|
// 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(
|
uniqueIndex('uniq_report_templates_port_kind_name').on(
|
||||||
table.portId,
|
table.portId,
|
||||||
table.kind,
|
table.kind,
|
||||||
@@ -51,3 +68,97 @@ export const reportTemplates = pgTable(
|
|||||||
|
|
||||||
export type ReportTemplate = typeof reportTemplates.$inferSelect;
|
export type ReportTemplate = typeof reportTemplates.$inferSelect;
|
||||||
export type NewReportTemplate = typeof reportTemplates.$inferInsert;
|
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<Array<{ name?: string; email: string }>>().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<Record<string, any>>().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<Array<{ name?: string; email: string }>>(),
|
||||||
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user