From 7c93d3e76600683589f07b7ec669c3f648323712 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 26 Aug 2026 21:42:14 +0200 Subject: [PATCH] feat(housekeeping): add audit and preference storage --- drizzle/migrations/0023_housekeeping.sql | 17 +++ src/db/schema.ts | 50 +++++-- .../foundation/persistence-contract.test.ts | 122 ++++++++++++++++++ 3 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 drizzle/migrations/0023_housekeeping.sql create mode 100644 src/features/housekeeping/foundation/persistence-contract.test.ts diff --git a/drizzle/migrations/0023_housekeeping.sql b/drizzle/migrations/0023_housekeeping.sql new file mode 100644 index 0000000000..f0ac7958cc --- /dev/null +++ b/drizzle/migrations/0023_housekeeping.sql @@ -0,0 +1,17 @@ +-- Housekeeping audit correlation and user presentation preferences. +-- Additive only: this shared schema is also consumed by the emulator. +ALTER TABLE `admin_audit_log` + ADD COLUMN `correlation_id` VARCHAR(64) NULL, + ADD COLUMN `outcome` VARCHAR(32) NULL, + ADD COLUMN `reason` TEXT NULL, + ADD COLUMN `domain` VARCHAR(32) NULL, + ADD INDEX `admin_audit_log_correlation_id_idx` (`correlation_id`); + +CREATE TABLE `housekeeping_user_preferences` ( + `user_id` INT NOT NULL, + `schema_version` INT NOT NULL DEFAULT 1, + `payload` LONGTEXT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`) +); \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 4f653fb5f4..2e0535756b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -11,6 +11,7 @@ import { datetime, decimal, double, + index, int, longtext, mediumtext, @@ -1149,21 +1150,42 @@ export const UserWatch = mysqlTable( ], ); -export const AdminAuditLog = mysqlTable("admin_audit_log", { - id: int("id").autoincrement().primaryKey().notNull(), - userId: int("user_id").notNull(), - action: varchar("action", { length: 191 }).notNull().default(""), - target: varchar("target", { length: 191 }).notNull().default(""), - targetId: int("target_id"), - details: text("details"), - before: text("before"), - after: text("after"), - diff: text("diff"), - ipAddress: varchar("ip_address", { length: 45 }), - createdAt: varchar("created_at", { length: 64 }).notNull().default(""), - updatedAt: varchar("updated_at", { length: 64 }), -}); +export const AdminAuditLog = mysqlTable( + "admin_audit_log", + { + id: int("id").autoincrement().primaryKey().notNull(), + userId: int("user_id").notNull(), + action: varchar("action", { length: 191 }).notNull().default(""), + target: varchar("target", { length: 191 }).notNull().default(""), + targetId: int("target_id"), + details: text("details"), + before: text("before"), + after: text("after"), + diff: text("diff"), + ipAddress: varchar("ip_address", { length: 45 }), + correlationId: varchar("correlation_id", { length: 64 }), + outcome: varchar("outcome", { length: 32 }), + reason: text("reason"), + domain: varchar("domain", { length: 32 }), + createdAt: varchar("created_at", { length: 64 }).notNull().default(""), + updatedAt: varchar("updated_at", { length: 64 }), + }, + (t) => [index("admin_audit_log_correlation_id_idx").on(t.correlationId)], +); +export const HousekeepingUserPreferences = mysqlTable( + "housekeeping_user_preferences", + { + userId: int("user_id").primaryKey().notNull(), + schemaVersion: int("schema_version").notNull().default(1), + payload: longtext("payload").notNull(), + createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), + }, +); + +export type HousekeepingUserPreferenceRow = + typeof HousekeepingUserPreferences.$inferSelect; export const AclRole = mysqlTable("acl_roles", { id: int("id").autoincrement().primaryKey().notNull(), slug: varchar("slug", { length: 191 }).unique().notNull(), diff --git a/src/features/housekeeping/foundation/persistence-contract.test.ts b/src/features/housekeeping/foundation/persistence-contract.test.ts new file mode 100644 index 0000000000..62bd373626 --- /dev/null +++ b/src/features/housekeeping/foundation/persistence-contract.test.ts @@ -0,0 +1,122 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const projectRoot = resolve(import.meta.dirname, "../../../.."); +const migrationPath = resolve( + projectRoot, + "drizzle/migrations/0023_housekeeping.sql", +); +const schemaPath = resolve(projectRoot, "src/db/schema.ts"); +const legacyAuditColumns = [ + "action", + "target", + "target_id", + "details", + "before", + "after", + "diff", + "ip_address", + "created_at", + "updated_at", +] as const; + +function migrationSource(): string { + return readFileSync(migrationPath, "utf8"); +} + +function schemaSource(): string { + return readFileSync(schemaPath, "utf8"); +} + +describe("housekeeping persistence contract", () => { + it("adds only the approved nullable audit columns and correlation index", () => { + const migration = migrationSource(); + const auditAlter = migration.match( + /ALTER TABLE `admin_audit_log`[\s\S]*?;/, + )?.[0]; + + expect(auditAlter).toBeDefined(); + expect(auditAlter).toContain( + "ADD COLUMN `correlation_id` VARCHAR(64) NULL", + ); + expect(auditAlter).toContain("ADD COLUMN `outcome` VARCHAR(32) NULL"); + expect(auditAlter).toContain("ADD COLUMN `reason` TEXT NULL"); + expect(auditAlter).toContain("ADD COLUMN `domain` VARCHAR(32) NULL"); + expect(auditAlter).toContain( + "ADD INDEX `admin_audit_log_correlation_id_idx` (`correlation_id`)", + ); + expect(auditAlter).not.toMatch(/\b(?:DROP|RENAME|MODIFY|CHANGE)\b/i); + for (const column of legacyAuditColumns) { + expect(auditAlter).not.toMatch( + new RegExp( + `\\b(?:ADD|DROP|RENAME|MODIFY|CHANGE)\\s+COLUMN\\s+\\\`${column}\\\``, + "i", + ), + ); + } + }); + + it("creates the exact user-scoped preference table with one primary key", () => { + const migration = migrationSource(); + const preferenceTable = migration.match( + /CREATE TABLE `housekeeping_user_preferences` \([\s\S]*?\);/, + )?.[0]; + + expect(preferenceTable).toBeDefined(); + expect(preferenceTable).toContain("`user_id` INT NOT NULL"); + expect(preferenceTable).toContain( + "`schema_version` INT NOT NULL DEFAULT 1", + ); + expect(preferenceTable).toContain("`payload` LONGTEXT NOT NULL"); + expect(preferenceTable).toContain( + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + ); + expect(preferenceTable).toContain( + "`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", + ); + expect(preferenceTable?.match(/PRIMARY KEY/g)).toHaveLength(1); + expect(preferenceTable).toContain("PRIMARY KEY (`user_id`)"); + }); + + it("declares matching additive Drizzle tables and the published preference row type", () => { + const schema = schemaSource(); + + expect(schema).toContain("export const AdminAuditLog = mysqlTable("); + expect(schema).toContain('"admin_audit_log"'); + expect(schema).toContain( + 'correlationId: varchar("correlation_id", { length: 64 })', + ); + expect(schema).toContain('outcome: varchar("outcome", { length: 32 })'); + expect(schema).toContain('reason: text("reason")'); + expect(schema).toContain('domain: varchar("domain", { length: 32 })'); + expect(schema).toContain( + 'index("admin_audit_log_correlation_id_idx").on(t.correlationId)', + ); + expect(schema).toContain( + "export const HousekeepingUserPreferences = mysqlTable(", + ); + expect(schema).toContain('"housekeeping_user_preferences"'); + expect(schema).toContain('userId: int("user_id").primaryKey().notNull()'); + expect(schema).toContain( + 'schemaVersion: int("schema_version").notNull().default(1)', + ); + expect(schema).toContain('payload: longtext("payload").notNull()'); + expect(schema).toContain( + 'createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`)', + ); + expect(schema).toContain( + 'updatedAt: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`)', + ); + expect(schema).toContain("export type HousekeepingUserPreferenceRow ="); + expect(schema).toContain( + "typeof HousekeepingUserPreferences.$inferSelect;", + ); + }); + + it("contains no destructive DDL", () => { + const migration = migrationSource(); + + expect(migration).not.toMatch(/\b(?:DROP|RENAME)\b/i); + }); +});