feat(housekeeping): add audit and preference storage
This commit is contained in:
1 parent
bdb8f0b02b
commit
7c93d3e766
3 files changed
+175
-14
No files matched your search
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user