# Audit Module — Architecture Map

> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Audit module provides **automatic, system-wide change tracking** for the entire ERP. It manages:
- Automatic logging of all INSERT, UPDATE, DELETE operations via Database hooks
- Before/after data capture with field-level change detection
- Authentication event logging (login, logout, failed login)
- Filterable audit log viewer with export to CSV
- Per-entity history view (timeline of all changes to a specific record)
- Sensitive field masking (passwords)
- Human-readable labels for actions, entity types, fields, and statuses

It does **NOT** directly manage:
- Application-level business event logging (that's EventBus)
- User session management (Auth module)
- Data retention/purging policies (no auto-cleanup exists)

---

## 2. Directory & File Structure

```
app/Modules/Audit/
├── bootstrap.php                    # Registers DB hooks + auth event listeners
├── Routes.php                       # 4 web routes
├── Controllers/
│   └── AuditController.php         # Index (search/filter), show, export, entity-history
├── Models/
│   └── AuditTrail.php              # Search, entity history, distinct actions/types
├── Services/
│   └── AuditService.php            # Core audit engine: logging, hooks, labels, formatting
└── Views/
    ├── index.php                    # Paginated audit log with filters
    ├── show.php                     # Single audit entry detail (before/after diff)
    └── entity-history.php           # All changes for one entity
```

---

## 3. Database Schema (Production — Source of Truth)

### 3.1 `audit_trail` Table (6,907 rows as of 2026-06-10)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| employee_id | bigint unsigned | YES | MUL | FK to employees (who did it) |
| employee_name | varchar(200) | YES | | Denormalized name (survives employee deletion) |
| action | varchar(50) | NO | MUL | create/update/delete/archive/login/logout/login_failed |
| entity_type | varchar(100) | YES | MUL | Table name (e.g. 'members', 'payments') |
| entity_id | bigint unsigned | YES | | Row ID in the entity table |
| entity_label | varchar(255) | YES | | Human-readable label (name, number, code) |
| before_data_json | json | YES | | Full record state before change |
| after_data_json | json | YES | | Full record state after change |
| changed_fields_json | json | YES | | Only changed fields with from/to values |
| ip_address | varchar(45) | YES | | Request IP |
| user_agent | varchar(500) | YES | | Browser user agent |
| session_id | varchar(128) | YES | | PHP session ID |
| route | varchar(255) | YES | | REQUEST_URI |
| notes | text | YES | | Additional context |
| created_at | timestamp | NO | MUL | When the action occurred |

**Current data distribution (action):**
- create: 4,497
- update: 2,021
- login: 355
- logout: 19
- delete: 15

**Top entity types by volume:**
- payments: 824
- sa_pool_zone_bookings: 732
- facility_grid_zones: 604
- employees: 466
- receipts: 412
- employee (auth events): 375
- installment_schedule: 334
- members: 324
- subscriptions: 269
- mirror_boxes: 244

---

## 4. How Audit Logging Works

### 4.1 Automatic Database Hooks (registered in bootstrap.php)

```
AuditService::registerDatabaseHooks()
         │
         ├── db->onAfterInsert(table, data, id)
         │        → log('create', table, id, label, null, data)
         │
         ├── db->onAfterUpdate(table, changedData, entityId, oldRecord, newRecord)
         │        → computeChangedFields(old, new)
         │        → skip if no real changes
         │        → log('update', table, id, label, old, new)
         │
         └── db->onAfterDelete(table, oldRecord, entityId)
                  → log('delete', table, id, label, old, null)
```

### 4.2 Manual Audit Logging (auth events)

```
EventBus::listen('auth.login')   → AuditService::log('login', ...)
EventBus::listen('auth.logout')  → AuditService::log('logout', ...)
EventBus::listen('auth.password_changed') → AuditService::log('update', ...)
```

### 4.3 IGNORE_TABLES (never audited)

These tables are excluded to prevent infinite loops or reduce noise:
- `audit_trail` (self — would cause infinite loop)
- `login_attempts`
- `active_sessions`
- `migrations`
- `seeds`
- `async_event_queue`
- `cron_job_log`
- `password_history`
- `sms_log`
- `notification_queue`

### 4.4 SENSITIVE_FIELDS (values masked as '***MASKED***')
- `password_hash`
- `password`

### 4.5 NOISE_FIELDS (excluded from change detection)
- `updated_at`

---

## 5. Events Consumed

| Event | Action |
|-------|--------|
| auth.login | Log login with IP |
| auth.logout | Log logout |
| auth.password_changed | Log password change |

---

## 6. Events Dispatched

None — the Audit module is a pure observer/sink.

---

## 7. Cross-Module Dependencies

### 7.1 Audit module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Core | App, Database (hooks), Logger |
| Cashier | PaymentRequestService::getPaymentTypeLabel() (for field formatting) |

### 7.2 Other modules that IMPORT FROM Audit:

| Module | What it imports | How |
|--------|----------------|-----|
| Members | AuditTrail model | Changelog view at /members/{id}/changelog |
| Dashboard | AuditService | Recent activity widget |
| Reports | ReportEngine | Audit data in reports |
| Users | PermissionAuditService | Permission change tracking |
| Inventory | StockAuditService/Controller | Stock-specific audit views |

### 7.3 Implicit dependency (via Database hooks):

Every module that uses `$db->insert()`, `$db->update()`, or `$db->delete()` **automatically** gets audited without any explicit import. This covers:
- Members, Payments, Cashier, Installments, Subscriptions, Fines, HR, Accounting, Inventory, SportsActivity, Reservations, etc.

---

## 8. Routes (4)

| Method | Path | Handler | Permission |
|--------|------|---------|------------|
| GET | /audit | AuditController@index | report.view_audit |
| GET | /audit/export | AuditController@export | report.view_audit |
| GET | /audit/{id} | AuditController@show | report.view_audit |
| GET | /audit/entity/{type}/{id} | AuditController@entityHistory | report.view_audit |

---

## 9. Permissions

| Key | Description | Registered By |
|-----|-------------|---------------|
| report.view_audit | View audit log | Branches/bootstrap.php |

Note: The Audit module does NOT have its own bootstrap.php menu/permission registration. The permission and sidebar entry are registered by the **Branches** module (which also handles "General Settings" menu including Audit Log link).

---

## 10. Background Processes (Cron)

None — audit logging is synchronous and happens inline with each database operation.

---

## 11. High-Risk Areas

### 11.1 Performance Impact (CRITICAL)
- Every single INSERT/UPDATE/DELETE triggers an audit log INSERT
- The `onAfterUpdate` hook fetches the old record BEFORE the update and the new record AFTER — this means **every update does 2 extra SELECT queries** plus 1 INSERT
- At 6,907 rows and growing, the table has no auto-purge mechanism
- No partitioning or archival strategy
- Index on `created_at` helps search but table will grow unbounded

### 11.2 JSON Storage Growth
- `before_data_json` and `after_data_json` store FULL records (all columns)
- For tables with TEXT/JSON columns (like `definition_json` in workflow_definitions), a single audit entry could be very large
- No compression or truncation of large field values

### 11.3 Circular Reference Prevention
- The IGNORE_TABLES list must be kept up to date
- If a new high-traffic table is added without being ignored, audit volume will spike
- The `audit_trail` INSERT uses raw SQL (bypasses hooks) to prevent infinite loops — this is correct but fragile

### 11.4 Employee Context in CLI
- In CLI context (migrations, seeds, cron), `currentEmployee()` returns null
- Audit entries from cron jobs will have `employee_id = NULL` and `employee_name = NULL`
- No way to distinguish "system" actions from "unauthenticated" issues

### 11.5 Denormalized employee_name
- Employee name is stored at audit time
- If employee name changes later, old audit entries retain the old name
- This is intentional (audit integrity) but can cause confusion

---

## 12. Label & Formatting System

AuditService provides extensive localization support:

- **getActionLabel()**: Maps action codes to Arabic labels (create→ إنشاء, update→تعديل, etc.)
- **getEntityTypeLabel()**: Maps table names to Arabic (members→عضو, payments→دفعة, etc.) — 27 mappings
- **getFieldLabel()**: Maps column names to Arabic (full_name_ar→الاسم بالعربية, etc.) — 50+ mappings
- **getStatusLabel()**: Maps status values to Arabic (active→نشط, frozen→مجمد, etc.) — 16 mappings
- **formatFieldValue()**: Formats money, booleans, status, payment types for display
- **getActionIcon()**: Emoji icons for each action type
- **getActionColor()**: Hex colors for UI styling per action

---

## 13. Known Patterns & Gotchas

1. **Self-bootstrapping safety**: `registerDatabaseHooks()` checks if `audit_trail` table exists before registering hooks — safe during initial migration
2. **No ORM events used**: Audit hooks are on the Database layer, not the Model layer — this means raw SQL via `$db->query()` / `$db->raw()` is NOT audited
3. **Entity label extraction**: Tries common fields in priority order: full_name_ar, name_ar, username, membership_number, form_number, branch_code, etc.
4. **Export limit**: CSV export caps at 10,000 rows regardless of filters
5. **Pagination**: Default 40 rows per page in the index view
6. **Change detection**: Compares string-cast values (`(string) $old !== (string) $new`) — numeric precision differences may not be caught
7. **No soft-delete audit differentiation**: An `UPDATE ... SET is_archived = 1` is logged as a regular 'update', not 'archive' — the 'archive' action label exists but is only used if manually called
