Commit 0a92504c authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(membership): resolve 5 confirmed bugs from full-scale module scan

1. Transfer & Divorce now block completion if source member has debts
   (extracted DebtCheckService from WaiverProcessor to shared service)
2. MemberNumberGenerator retries on UNIQUE constraint race (3 attempts)
3. Acquired member detection uses direct members.transferred_from_* columns
   instead of fragile 4-table query with exception swallowing
4. Waiver auto-complete moved to cron job (unblocks cashier HTTP response)
5. Reconcile on profile view now flashes warning when status changes

Also deletes all 77 outdated architecture maps per request.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e7aa9385
...@@ -324,6 +324,11 @@ class DivorceController extends Controller ...@@ -324,6 +324,11 @@ class DivorceController extends Controller
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$debtCheck = \App\Shared\Services\DebtCheckService::check((int) $case['member_id']);
if (!$debtCheck['clear']) {
return $this->redirect("/divorce/{$id}")->withError('لا يمكن إتمام الطلاق — مديونيات بإجمالي ' . $debtCheck['total'] . ' ج.م. يرجى سدادها أولاً.');
}
$db->beginTransaction(); $db->beginTransaction();
try { try {
$snapshotId = ArchiveService::takeSnapshot('members', (int) $case['member_id'], 'divorce', 'طلاق — حالة #' . $id); $snapshotId = ArchiveService::takeSnapshot('members', (int) $case['member_id'], 'divorce', 'طلاق — حالة #' . $id);
......
...@@ -229,8 +229,15 @@ class MemberController extends Controller ...@@ -229,8 +229,15 @@ class MemberController extends Controller
try { $temporaries = $db->select("SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id", [(int) $id]); } catch (\Throwable $e2) {} try { $temporaries = $db->select("SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id", [(int) $id]); } catch (\Throwable $e2) {}
} }
// Reconcile membership status against payment source-of-truth $reconcileResult = \App\Modules\Members\Services\MembershipPaymentGuard::reconcile((int) $id);
\App\Modules\Members\Services\MembershipPaymentGuard::reconcile((int) $id); if (!empty($reconcileResult['changes'])) {
$session = \App\Core\App::getInstance()->session();
$session->flash('_alerts', [[
'type' => 'warning',
'message' => 'تم تصحيح حالة العضوية تلقائياً: ' . implode('، ', $reconcileResult['changes']),
]]);
$member = \App\Modules\Members\Models\Member::find((int) $id);
}
$bill = BillingService::getMemberBill((int) $id); $bill = BillingService::getMemberBill((int) $id);
$membershipType = $member->membership_type ?? 'working'; $membershipType = $member->membership_type ?? 'working';
......
...@@ -16,7 +16,6 @@ final class MemberNumberGenerator ...@@ -16,7 +16,6 @@ final class MemberNumberGenerator
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
// Check member exists and doesn't already have a number
$member = $db->selectOne( $member = $db->selectOne(
"SELECT id, membership_number FROM members WHERE id = ? AND is_archived = 0", "SELECT id, membership_number FROM members WHERE id = ? AND is_archived = 0",
[$memberId] [$memberId]
...@@ -24,20 +23,36 @@ final class MemberNumberGenerator ...@@ -24,20 +23,36 @@ final class MemberNumberGenerator
if (!$member) return null; if (!$member) return null;
if (!empty($member['membership_number'])) return $member['membership_number']; if (!empty($member['membership_number'])) return $member['membership_number'];
// Get next available membership number for ($attempt = 1; $attempt <= 3; $attempt++) {
$nextNumber = self::getNextMembershipNumber(); $nextNumber = self::getNextMembershipNumber();
if ($nextNumber === null) return null; if ($nextNumber === null) return null;
$numberStr = (string) $nextNumber; $numberStr = (string) $nextNumber;
$db->update( try {
'members', $db->update(
['membership_number' => $numberStr, 'updated_at' => date('Y-m-d H:i:s')], 'members',
'`id` = ?', ['membership_number' => $numberStr, 'updated_at' => date('Y-m-d H:i:s')],
[$memberId] '`id` = ?',
); [$memberId]
);
return $numberStr;
} catch (\PDOException $e) {
if ($e->getCode() === '23000' || str_contains($e->getMessage(), '1062')) {
\App\Core\Logger::warning("MemberNumberGenerator: race on attempt {$attempt}", [
'member_id' => $memberId, 'number' => $numberStr,
]);
if ($attempt === 3) {
\App\Core\Logger::error("MemberNumberGenerator: exhausted retries for member #{$memberId}");
return null;
}
continue;
}
throw $e;
}
}
return $numberStr; return null;
} }
/** /**
......
...@@ -278,27 +278,17 @@ final class SpouseFeeCalculator ...@@ -278,27 +278,17 @@ final class SpouseFeeCalculator
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$tables = [ $row = $db->selectOne(
['transfer_requests', 'target_member_id'], "SELECT id FROM members WHERE id = ? AND (
['divorce_cases', 'spouse_new_member_id'], transferred_from_transfer_id IS NOT NULL
['death_cases', 'transferred_to_member_id'], OR transferred_from_death_id IS NOT NULL
['waiver_requests', 'target_member_id'], OR transferred_from_waiver_id IS NOT NULL
]; OR transferred_from_divorce_id IS NOT NULL
) LIMIT 1",
foreach ($tables as [$table, $column]) { [$memberId]
try { );
if (!$db->tableExists($table)) continue;
$row = $db->selectOne( return $row !== null && $row !== false;
"SELECT id FROM `{$table}` WHERE `{$column}` = ? AND status = 'completed' LIMIT 1",
[$memberId]
);
if ($row) return true;
} catch (\Throwable $e) {
continue;
}
}
return false;
} }
private static function buildBreakdown( private static function buildBreakdown(
......
...@@ -42,6 +42,11 @@ final class TransferProcessor ...@@ -42,6 +42,11 @@ final class TransferProcessor
return ['success' => false, 'error' => 'العضو المصدر غير موجود']; return ['success' => false, 'error' => 'العضو المصدر غير موجود'];
} }
$debtCheck = \App\Shared\Services\DebtCheckService::check((int) $request['source_member_id']);
if (!$debtCheck['clear']) {
return ['success' => false, 'error' => 'لا يمكن إتمام التحويل — مديونيات على العضو المصدر بإجمالي ' . $debtCheck['total'] . ' ج.م'];
}
$db->beginTransaction(); $db->beginTransaction();
try { try {
// 1. Take archive snapshot of source member (skip for child_separation — parent keeps their membership) // 1. Take archive snapshot of source member (skip for child_separation — parent keeps their membership)
......
...@@ -3,19 +3,12 @@ declare(strict_types=1); ...@@ -3,19 +3,12 @@ declare(strict_types=1);
use App\Core\EventBus; use App\Core\EventBus;
use App\Core\Logger; use App\Core\Logger;
use App\Modules\Waiver\Services\WaiverProcessor;
// Auto-complete waiver after fee payment // Waiver auto-complete is handled by cron (WaiverAutoCompleteJob)
// to avoid blocking the cashier HTTP response with 30-50 DB queries.
EventBus::listen('waiver.fee_paid', function (array $data): void { EventBus::listen('waiver.fee_paid', function (array $data): void {
$waiverId = (int) ($data['waiver_id'] ?? 0); $waiverId = (int) ($data['waiver_id'] ?? 0);
if ($waiverId <= 0) return; if ($waiverId > 0) {
Logger::info("Waiver #{$waiverId} fee paid — queued for cron auto-complete");
try {
$result = WaiverProcessor::autoComplete($waiverId);
if (!$result['success']) {
Logger::info("Waiver #{$waiverId} auto-complete deferred: " . ($result['reason'] ?? 'unknown'), $data);
}
} catch (\Throwable $e) {
Logger::error("Waiver auto-complete failed for #{$waiverId}: " . $e->getMessage(), $data);
} }
}); });
This diff is collapsed.
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\App;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\Waiver\Services\WaiverProcessor;
class WaiverAutoCompleteJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
$pending = $this->db->select(
"SELECT id FROM waiver_requests WHERE status = 'fee_paid' ORDER BY updated_at ASC LIMIT 10"
);
if (empty($pending)) {
return ['processed' => 0, 'pending' => 0];
}
$processed = 0;
$deferred = 0;
foreach ($pending as $row) {
try {
$result = WaiverProcessor::autoComplete((int) $row['id']);
if ($result['success'] ?? false) {
$processed++;
Logger::info("WaiverAutoCompleteJob: completed waiver #{$row['id']}");
} else {
$deferred++;
Logger::info("WaiverAutoCompleteJob: deferred waiver #{$row['id']}: " . ($result['reason'] ?? 'unknown'));
}
} catch (\Throwable $e) {
Logger::error("WaiverAutoCompleteJob: failed waiver #{$row['id']}: " . $e->getMessage());
}
}
return ['processed' => $processed, 'deferred' => $deferred, 'total_pending' => count($pending)];
}
}
This diff is collapsed.
This diff is collapsed.
# AccessMatrix Module — Architecture Map
> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered
---
## 1. Purpose & Responsibilities
The AccessMatrix module provides a **visual permission management interface** for the ERP. It manages:
- Full matrix view of all roles vs. all registered permissions (checkbox grid)
- Role comparison (diff two roles to see unique/shared permissions)
- Permission toggle (grant/revoke individual permissions per role)
- Role cloning (duplicate a role's permissions into a new role)
- Permission health audit (orphan permissions, unprotected routes, dependency violations)
- CSV export of the complete matrix
It does **NOT** directly manage:
- Role CRUD (roles table is managed elsewhere, matrix just reads/modifies permissions)
- User-to-role assignment (handled by HR/Auth modules)
- Permission registration (each module registers its own permissions in bootstrap.php)
---
## 2. Directory & File Structure
```
app/Modules/AccessMatrix/
├── bootstrap.php # Permission + menu registration
├── Routes.php # 6 routes
├── Controllers/
│ └── AccessMatrixController.php # All actions (index, compare, toggle, export, clone, health)
├── Services/
│ ├── MatrixService.php # Build matrix, compare roles, clone roles, export CSV
│ └── PermissionDiscoveryService.php # Orphan detection, unprotected routes, dependency validation
└── Views/
├── index.php # Full matrix grid with toggle checkboxes + clone modal
├── compare.php # Side-by-side role comparison
└── health.php # Permission health dashboard
```
---
## 3. Database Schema (Production — Source of Truth)
### 3.1 `roles` Table (29 active rows as of 2026-06-10)
| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| role_code | varchar(50) | NO | UNI | Unique role identifier |
| name_ar | varchar(200) | NO | | Arabic display name |
| name_en | varchar(200) | YES | | English display name |
| description_ar | varchar(500) | YES | | |
| description_en | varchar(500) | YES | | |
| is_system | tinyint(1) | NO | | Default: 0 |
| is_active | tinyint(1) | NO | | Default: 1 |
| parent_role_id | bigint unsigned | YES | MUL | FK to roles (self-ref hierarchy) |
| has_all_branches | tinyint(1) | NO | | Default: 0 |
| category | varchar(50) | YES | | Role grouping category |
| level | tinyint unsigned | NO | | Sort order / hierarchy level, default 0 |
| is_template | tinyint(1) | NO | | Default: 0 |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | On update cascade |
| created_by | bigint unsigned | YES | | FK to employees |
| updated_by | bigint unsigned | YES | | FK to employees |
### 3.2 `role_permissions` Table (656 rows as of 2026-06-10)
| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| role_id | bigint unsigned | NO | MUL | FK to roles |
| permission_key | varchar(100) | NO | MUL | Permission string (e.g. 'member.view') |
| granted_at | timestamp | NO | | When permission was granted |
| granted_by | bigint unsigned | YES | | FK to employees (who granted it) |
### 3.3 `permission_dependencies` Table (95 rows as of 2026-06-10)
| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| permission_key | varchar(100) | NO | MUL | The permission that depends on another |
| requires_key | varchar(100) | NO | MUL | The required permission |
| is_auto_grant | tinyint(1) | NO | | Default: 1; if true, granting parent auto-grants child |
---
## 4. Routes
| Method | Path | Action | Middleware | Permission |
|--------|------|--------|------------|------------|
| GET | /access-matrix | index | auth | access_matrix.view |
| GET | /access-matrix/compare | compare | auth | access_matrix.view |
| POST | /access-matrix/toggle | toggle | auth, csrf | access_matrix.manage |
| GET | /access-matrix/export | export | auth | access_matrix.view |
| POST | /access-matrix/clone | clone | auth, csrf | access_matrix.manage |
| GET | /access-matrix/health | health | auth | access_matrix.view |
---
## 5. Core Business Flows
### 5.1 Matrix View (index)
1. `MatrixService::buildMatrix()` queries all active roles ordered by `level`
2. Reads all registered permissions from `PermissionRegistry::getAllGrouped()`
3. For each role, queries `role_permissions` to get assigned keys
4. Wildcard check: if a role has `*` permission_key, all checkboxes show as granted
5. Returns grid data: groups > permissions > roles (granted: bool)
### 5.2 Permission Toggle (AJAX)
1. Receives `role_id` + `permission_key`
2. Validates role exists
3. Checks if `role_permissions` row exists for that combination
4. If exists: DELETE (revoke); if not: INSERT (grant)
5. Returns JSON `{granted: bool}`
### 5.3 Role Clone
1. Validates source role exists and new code is unique
2. Creates new role with same properties (sets `is_system=0`, `is_template=0`, `is_active=1`)
3. Copies all `role_permissions` from source to new role
### 5.4 Health Audit
- **Orphan Permissions**: Registered in `PermissionRegistry` but not used in any route's permission slot
- **Unprotected Routes**: Routes that have no permission key in position [4]
- **Dependency Violations**: Roles that have permission X which requires permission Y, but Y is missing
- **Stats**: Total permissions, group count, distribution by group
---
## 6. Events Dispatched
None. This module does not dispatch any events.
---
## 7. Events Consumed
None. This module does not listen to any events.
---
## 8. Cross-Module Dependencies
### 8.1 AccessMatrix IMPORTS FROM:
| Module/Component | What it uses |
|-----------------|-------------|
| App\Core\Registries\PermissionRegistry | `getAllGrouped()`, `getAll()` — reads all registered permissions from all modules |
| App\Core\Registries\MenuRegistry | Menu registration (bootstrap) |
### 8.2 Other modules that IMPORT FROM AccessMatrix:
None. No other modules reference AccessMatrix services or controllers.
### 8.3 Database Dependencies (reads from other modules' data)
| Table | Owner Module | How it's used |
|-------|-------------|---------------|
| roles | Auth/Settings | Read roles, insert cloned roles |
| role_permissions | Auth/Settings | Read, insert, delete permissions |
| permission_dependencies | Auth/Settings | Read dependency rules for health validation |
| employees | HR | Read current employee for `granted_by` |
---
## 9. Permissions
| Key | Description |
|-----|-------------|
| access_matrix.view | View the matrix, compare roles, export CSV, view health |
| access_matrix.manage | Toggle permissions, clone roles |
---
## 10. Background Processes (Cron)
None.
---
## 11. High-Risk Areas
### 11.1 Permission Toggle (CRITICAL)
- Direct manipulation of `role_permissions` table — no audit trail beyond `granted_at`/`granted_by`
- No confirmation step — single AJAX call grants/revokes
- Wildcard `*` roles can't be modified via toggle (they already have everything)
- If system roles (`is_system=1`) are modified, could break expected default behavior
### 11.2 Role Clone
- Creates a new role immediately — no approval workflow
- If source role has `*` permission, the clone also gets all individual permission rows (not wildcard)
- No way to undo a clone (must manually delete the role)
### 11.3 PermissionDiscoveryService File Scanning
- `findUnprotectedRoutes()` and `findOrphanPermissions()` scan ALL module Routes.php files via `glob()`
- Uses `require` to load route files — any side effects in route files will execute
- Performance: re-reads all route files on every health page load (no caching)
### 11.4 Dependency Validation
- `permission_dependencies` table data must be maintained manually
- If dependencies are stale or missing, health audit won't catch real violations
- No enforcement — violations are reported but permissions can still be granted
---
## 12. Known Patterns & Gotchas
1. **No EventBus usage**: This module is purely CRUD on permission data — no events dispatched or consumed
2. **PermissionRegistry is read-only**: The matrix doesn't modify what permissions exist, only which roles have them
3. **Wildcard permission**: A role with `permission_key = '*'` in `role_permissions` is treated as having all permissions
4. **CSV export includes BOM**: Output starts with `\xEF\xBB\xBF` for proper Excel Arabic support
5. **Health page performance**: Scans all Routes.php files + validates all roles' dependencies on every load
6. **No soft delete on roles/role_permissions**: Revoked permissions are hard-deleted
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
# Dashboard Module — Architecture Map
> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered
---
## 1. Purpose & Responsibilities
The Dashboard module is the **main landing page** after login. It provides a real-time operational overview by aggregating key metrics from across the ERP:
- Active member count
- New members this month
- Monthly revenue (from Payments)
- Pending interviews
- Overdue installments
- Members by branch distribution
- Monthly revenue trend (6-month chart)
- Recent audit trail activity
- Contextual alert notifications (children aging, expiring forms, overdue installments, honorary expiry)
It also **registers sidebar menu separators** that structure the global navigation (Operations, Finance, Supply Chain, Administration, Reports & System sections).
It does **NOT**:
- Provide any data mutation (read-only module)
- Have its own database tables
- Register any permissions (accessible to all authenticated users)
- Dispatch or listen to events
---
## 2. Directory & File Structure
```
app/Modules/Dashboard/
├── bootstrap.php # Menu registration + sidebar separators
├── Routes.php # Single route: GET /dashboard
├── Controllers/
│ └── DashboardController.php # Minimal — delegates to DashboardDataService
├── Services/
│ └── DashboardDataService.php # All data aggregation logic (8 queries + alerts)
└── Views/
├── index.php # Main dashboard view (cards, charts, alerts, activity)
└── _partials/
└── widgets.php # Reusable widget components
```
---
## 3. Database Schema (Production — Source of Truth)
The Dashboard module **does not own any tables**. It performs read-only queries against tables owned by other modules:
| Table Queried | Owner Module | Data Extracted |
|--------------|--------------|----------------|
| members | Members | COUNT active, COUNT new this month |
| payments | Payments | SUM amount for monthly revenue |
| interviews | Interviews | COUNT pending/scheduled |
| installment_plans + installment_schedule | Installments | COUNT overdue installments |
| branches | Branches | Member distribution by branch |
| audit_trail | Audit | Recent 15 activity entries |
| children | Children | Males approaching age 25 |
| form_submissions | Forms | Forms expiring within 3 days |
| honorary_members | Honorary | Memberships expiring within 30 days |
---
## 4. Routes
| Method | Path | Handler | Middleware | Permission |
|--------|------|---------|------------|------------|
| GET | /dashboard | DashboardController@index | auth | null (any authenticated user) |
---
## 5. Events Dispatched
None.
---
## 6. Events Consumed
None.
---
## 7. Cross-Module Dependencies
### 7.1 Dashboard IMPORTS FROM (via direct SQL queries):
| Module | What it queries |
|--------|----------------|
| Members | `members` table — active count, new this month, branch distribution |
| Payments | `payments` table — monthly revenue aggregation |
| Interviews | `interviews` table — pending interview count |
| Installments | `installment_plans` + `installment_schedule` — overdue count |
| Branches | `branches` table — branch names for distribution chart |
| Audit | `audit_trail` table — recent 15 activity entries |
| Children | `children` table — males nearing age 25 |
| Forms | `form_submissions` table — expiring forms |
| Honorary | `honorary_members` table — expiring honorary memberships |
### 7.2 Other modules that IMPORT FROM Dashboard:
None. The Dashboard module is a pure consumer — no other module depends on it.
### 7.3 Sidebar Separator Registration
The Dashboard bootstrap.php registers **global menu separators** that other modules rely on for navigation structure:
| Separator Key | Label | Order |
|--------------|-------|-------|
| `_sep_operations` | العمليات (Operations) | 50 |
| `_sep_finance` | المالية (Finance) | 295 |
| `_sep_supply` | سلسلة التوريد (Supply Chain) | 395 |
| `_sep_hr` | الشئون الإدارية (Administration) | 495 |
| `_sep_reports` | التقارير والنظام (Reports & System) | 595 |
---
## 8. Permissions
None registered. The dashboard route has `permission: null`, meaning any authenticated user can access it.
---
## 9. Background Processes (Cron)
None.
---
## 10. High-Risk Areas
### 10.1 Query Performance on Large Datasets
`DashboardDataService::getData()` executes **8 separate database queries** plus up to 4 alert-checking queries on every page load. No caching layer exists.
- Monthly revenue query scans payments for last 6 months
- Branch distribution query uses LEFT JOIN across all active branches
- Overdue installments query joins two tables
- If tables grow large (10K+ members, 50K+ payments), dashboard load time will degrade
### 10.2 Silent Failure Pattern
Every query is wrapped in `try/catch (\Throwable)` with a fallback to 0 or empty array. This means:
- Database connection issues will produce a dashboard with all zeros
- Schema changes that break queries will fail silently
- No logging of query failures — hard to diagnose production issues
### 10.3 Alert Queries Duplicate Logic
The `getAlerts()` method re-implements alert logic that also exists in:
- `AlertProcessorService` (Alerts module)
- `AutoFreezeService` (Members module)
If business rules change (e.g., children aging threshold), this duplicated logic may not be updated consistently.
---
## 11. Known Patterns & Gotchas
1. **No permission check**: Any logged-in user sees all dashboard data regardless of their role — there is no branch-level or permission-level filtering of the metrics
2. **Static SQL aggregation**: Revenue is `SUM(amount)` without filtering by branch — shows club-wide totals
3. **Audit trail displays employee_name directly**: If an employee name changes, old audit entries show the old name
4. **View uses inline styles heavily**: The index.php view has no CSS classes — all styling is inline, making it difficult to theme
5. **Chart is pure CSS**: Revenue chart uses CSS height percentages, not a JS charting library — limited interactivity
6. **Alert links assume routes exist**: Links like `/reports/view/RPT_CHILDREN_25` and `/installments?overdue_only=1` will 404 if those modules are disabled
---
## 12. Configuration Dependencies
None. The Dashboard module has no system_config, rule engine, or service catalog dependencies.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment