feat(housekeeping): add audit and preference storage

This commit is contained in:
Simo committed 2026-08-26 21:42:14 +02:00
1 parent bdb8f0b02b
commit 7c93d3e766
3 files changed
+175 -14

No files matched your search

+17
View File
@@ -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`)
);
+36 -14
View File
@@ -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(),
@@ -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);
});
});