Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions migrations/1768213263723_create-audit-logs-table.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
exports.shorthands = undefined;

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
exports.up = async (pgm) => {
await pgm.createTable("audit_logs", {
id: { type: "serial", primaryKey: true },
table_name: { type: "varchar(100)", notNull: true },
record_id: { type: "integer", notNull: true },
action: { type: "varchar(20)", notNull: true },
old_values: { type: "jsonb" },
new_values: { type: "jsonb" },
changed_fields: { type: "text[]" },
user_id: {
type: "integer",
references: "users(id)",
onDelete: "SET NULL",
},
actor_email: { type: "varchar(255)" },
actor_type: { type: "varchar(50)", default: "'system'" },
migration_name: { type: "varchar(100)" },
created_at: {
type: "timestamptz",
notNull: true,
default: pgm.func("NOW()"),
},
});

// Index for querying history of a specific record
await pgm.createIndex("audit_logs", ["table_name", "record_id"], {
name: "idx_audit_logs_table_record",
});

// Index for time-based queries
await pgm.createIndex("audit_logs", ["created_at"], {
name: "idx_audit_logs_created_at",
});

// Partial index for filtering by actor
await pgm.createIndex("audit_logs", ["user_id"], {
name: "idx_audit_logs_user_id",
where: "user_id IS NOT NULL",
});

// Partial index for migration-related changes
await pgm.createIndex("audit_logs", ["migration_name"], {
name: "idx_audit_logs_migration",
where: "migration_name IS NOT NULL",
});
};

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
exports.down = async (pgm) => {
await pgm.dropIndex("audit_logs", [], { name: "idx_audit_logs_migration" });
await pgm.dropIndex("audit_logs", [], { name: "idx_audit_logs_user_id" });
await pgm.dropIndex("audit_logs", [], { name: "idx_audit_logs_created_at" });
await pgm.dropIndex("audit_logs", [], { name: "idx_audit_logs_table_record" });
await pgm.dropTable("audit_logs");
};
96 changes: 96 additions & 0 deletions migrations/1768213263724_create-audit-trigger-function.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
exports.shorthands = undefined;

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
exports.up = async (pgm) => {
await pgm.db.query(`
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_old_values JSONB;
v_new_values JSONB;
v_changed_fields TEXT[];
v_record_id INTEGER;
v_user_id INTEGER;
v_actor_email VARCHAR(255);
v_actor_type VARCHAR(50);
v_migration_name VARCHAR(100);
v_key TEXT;
BEGIN
-- Get context from session variables (set by application or migrations)
v_user_id := NULLIF(current_setting('app.actor_user_id', true), '')::INTEGER;
v_actor_email := NULLIF(current_setting('app.actor_email', true), '');
v_actor_type := COALESCE(NULLIF(current_setting('app.actor_type', true), ''), 'system');
v_migration_name := NULLIF(current_setting('app.current_migration', true), '');

-- Determine record_id (handle composite keys for users_organizations)
IF TG_TABLE_NAME = 'users_organizations' THEN
v_record_id := COALESCE(NEW.user_id, OLD.user_id);
ELSE
v_record_id := COALESCE(NEW.id, OLD.id);
END IF;

-- Set old/new values based on operation
IF TG_OP = 'INSERT' THEN
v_old_values := NULL;
v_new_values := to_jsonb(NEW);
v_changed_fields := NULL;
ELSIF TG_OP = 'UPDATE' THEN
v_old_values := to_jsonb(OLD);
v_new_values := to_jsonb(NEW);
-- Compute changed fields
SELECT ARRAY_AGG(key)
INTO v_changed_fields
FROM jsonb_each(v_new_values) AS n(key, value)
WHERE v_old_values->key IS DISTINCT FROM n.value;
ELSIF TG_OP = 'DELETE' THEN
v_old_values := to_jsonb(OLD);
v_new_values := NULL;
v_changed_fields := NULL;
END IF;

-- Insert audit log entry
INSERT INTO audit_logs (
table_name,
record_id,
action,
old_values,
new_values,
changed_fields,
user_id,
actor_email,
actor_type,
migration_name
) VALUES (
TG_TABLE_NAME,
v_record_id,
TG_OP,
v_old_values,
v_new_values,
v_changed_fields,
v_user_id,
v_actor_email,
v_actor_type,
v_migration_name
);

RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
`);
};

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
exports.down = async (pgm) => {
await pgm.db.query(`DROP FUNCTION IF EXISTS audit_trigger_func();`);
};
43 changes: 43 additions & 0 deletions migrations/1768213263725_apply-audit-triggers.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
exports.shorthands = undefined;

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
exports.up = async (pgm) => {
// Apply audit trigger to users table
await pgm.db.query(`
CREATE TRIGGER audit_users
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
`);

// Apply audit trigger to organizations table
await pgm.db.query(`
CREATE TRIGGER audit_organizations
AFTER INSERT OR UPDATE OR DELETE ON organizations
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
`);

// Apply audit trigger to users_organizations table
await pgm.db.query(`
CREATE TRIGGER audit_users_organizations
AFTER INSERT OR UPDATE OR DELETE ON users_organizations
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
`);
};

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
exports.down = async (pgm) => {
await pgm.db.query(`DROP TRIGGER IF EXISTS audit_users_organizations ON users_organizations;`);
await pgm.db.query(`DROP TRIGGER IF EXISTS audit_organizations ON organizations;`);
await pgm.db.query(`DROP TRIGGER IF EXISTS audit_users ON users;`);
};
Loading
Loading