Skip to content

✨ Add audit logs system for tracking data changes#1671

Draft
douglasduteil wants to merge 2 commits into
mainfrom
douglasduteil/The-audit-table
Draft

✨ Add audit logs system for tracking data changes#1671
douglasduteil wants to merge 2 commits into
mainfrom
douglasduteil/The-audit-table

Conversation

@douglasduteil

@douglasduteil douglasduteil commented Jan 12, 2026

Copy link
Copy Markdown
Contributor

Problem

No way to track historical changes to users, organizations, and memberships.

Proposal

Add audit_logs table with triggers on users, organizations, and users_organizations tables.

**Problem**

No way to track historical changes to users, organizations, and memberships.

**Proposal**

Add audit_logs table with triggers on users, organizations, and users_organizations tables.
**Problem**

Drizzle schema is out of sync after adding audit_logs table.

**Proposal**

Update Drizzle schema and relations to include the new audit_logs table.
@douglasduteil douglasduteil force-pushed the douglasduteil/The-audit-table branch from 0a46691 to 5462da0 Compare January 12, 2026 10:52
@douglasduteil

Copy link
Copy Markdown
Contributor Author

Audit Logs Query Examples

Basic Queries

View all audit logs for a specific user

SELECT id, table_name, action, changed_fields, actor_email, created_at
FROM audit_logs
WHERE record_id = :user_id AND table_name = 'users'
ORDER BY created_at DESC;

View recent changes by actor

SELECT id, table_name, record_id, action, created_at
FROM audit_logs
WHERE actor_email = :actor_email
ORDER BY created_at DESC
LIMIT 100;

View all changes from a specific migration

SELECT id, table_name, record_id, action, changed_fields
FROM audit_logs
WHERE migration_name = :migration_name
ORDER BY id;

Point-in-Time Reconstruction

Reconstruct user state at a specific timestamp

-- Get the most recent audit entry for a user before the target time
SELECT
    CASE
        WHEN action = 'DELETE' THEN old_values
        ELSE new_values
    END as state_at_time
FROM audit_logs
WHERE table_name = 'users'
  AND record_id = :user_id
  AND created_at <= :target_timestamp
ORDER BY created_at DESC
LIMIT 1;

Full history reconstruction for a user

-- Returns all states a user has been in, chronologically
SELECT
    created_at,
    action,
    CASE
        WHEN action = 'INSERT' THEN new_values
        WHEN action = 'UPDATE' THEN new_values
        WHEN action = 'DELETE' THEN old_values
    END as state,
    changed_fields,
    actor_email
FROM audit_logs
WHERE table_name = 'users'
  AND record_id = :user_id
ORDER BY created_at;

Moderation Context Queries

Get user state at the time of moderation

-- Given a moderation created_at timestamp, find the user's state at that moment
SELECT
    m.id as moderation_id,
    m.type as moderation_type,
    m.created_at as moderation_time,
    al.new_values as user_state_at_moderation
FROM moderations m
CROSS JOIN LATERAL (
    SELECT new_values
    FROM audit_logs
    WHERE table_name = 'users'
      AND record_id = m.user_id
      AND created_at <= m.created_at
    ORDER BY created_at DESC
    LIMIT 1
) al
WHERE m.id = :moderation_id;

Get organization state at moderation time

SELECT
    m.id as moderation_id,
    m.type as moderation_type,
    m.created_at as moderation_time,
    al.new_values as org_state_at_moderation
FROM moderations m
CROSS JOIN LATERAL (
    SELECT new_values
    FROM audit_logs
    WHERE table_name = 'organizations'
      AND record_id = m.organization_id
      AND created_at <= m.created_at
    ORDER BY created_at DESC
    LIMIT 1
) al
WHERE m.id = :moderation_id;

Get user-organization link state at moderation time

SELECT
    m.id as moderation_id,
    m.created_at as moderation_time,
    al.new_values as link_state_at_moderation
FROM moderations m
CROSS JOIN LATERAL (
    SELECT new_values
    FROM audit_logs
    WHERE table_name = 'users_organizations'
      AND record_id = m.user_id
      AND (new_values->>'organization_id')::int = m.organization_id
      AND created_at <= m.created_at
    ORDER BY created_at DESC
    LIMIT 1
) al
WHERE m.id = :moderation_id;

Change Analysis Queries

Find all changes to a specific field

SELECT id, record_id, created_at, actor_email,
       old_values->>'email' as old_email,
       new_values->>'email' as new_email
FROM audit_logs
WHERE table_name = 'users'
  AND action = 'UPDATE'
  AND 'email' = ANY(changed_fields)
ORDER BY created_at DESC;

Count changes by table and action

SELECT table_name, action, COUNT(*) as change_count
FROM audit_logs
GROUP BY table_name, action
ORDER BY table_name, action;

Find suspicious activity (many changes in short time)

SELECT
    actor_email,
    COUNT(*) as changes,
    MIN(created_at) as first_change,
    MAX(created_at) as last_change
FROM audit_logs
WHERE created_at > NOW() - INTERVAL '1 hour'
GROUP BY actor_email
HAVING COUNT(*) > 10
ORDER BY changes DESC;

Setting Context Variables

In application code (before making changes)

-- Set actor context for tracking who made the change
SET LOCAL app.actor_email = 'user@example.com';
SET LOCAL app.actor_type = 'user';  -- or 'moderator', 'system', 'migration'

-- Then perform your INSERT/UPDATE/DELETE
UPDATE users SET email = 'new@example.com' WHERE id = 123;

In migrations

-- Set migration context
SET LOCAL app.current_migration = '1768213263725_apply-audit-triggers';

-- Perform migration operations
UPDATE organizations SET cached_libelle = 'New Name' WHERE id = 1;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant