# Post-Upgrade Audit — WCL Gym Management System

Second-pass audit of the Laravel 12 application: re-analysis, module-by-module
testing, gap analysis against enterprise norms, and the features implemented as
a result.

Companion to `LARAVEL_12_UPGRADE.md`, which covers the 8 → 12 upgrade itself.

---

## 0. Starting position — the application was broken

Four view files had been edited between sessions. Two were fine (a **WCL Gym**
rebrand of the sign-in screen and the printed admission form); two had reverted
to the pre-upgrade Ace template:

| File | State found | Action |
|---|---|---|
| `layouts/dashboard.blade.php` | Reverted; `@include`s four partials that no longer exist | Restored the Laravel 12 layout |
| `member/index.blade.php` | Reverted to Bootstrap 3 markup | Restored the design-system version |
| `auth/login.blade.php` | Rebranded to WCL Gym | **Kept**, wired to config |
| `member/show.blade.php` | Rebranded with new WCL address | **Kept**, wired to config |

Verified: `/login` returned 200 but **every authenticated page returned 500**
(`View [admin.includes.header] not found`).

Because a rebrand had needed edits in four separate files, branding is now
centralised in **`config/company.php`** (name, short name, legal name, address,
phone, email, website, logo). The sidebar, sign-in screen, page titles and
printed admission form all read from it. Changing the gym's details is now one
file, or environment variables.

---

## 1. Module inventory

| Module | Routes | Controller | Tables | Status |
|---|---|---|---|---|
| Authentication | 4 | `Auth\LoginController` | `users` | **Extended** — active check, last-login, audit |
| Dashboard | 1 | `DashboardController` | derived | Pass |
| Members | 14 | `MemberController` | `profiles`, `package_*` | **Extended** — archive, duplicate guard, audit |
| Subscriptions | 2 | `MemberController` | `package_profile`, `package_histories` | **Extended** — audit |
| Admissions | 2 | `MemberController` | `admission_histories` | Pass |
| **Executives** | 8 | `ExecutiveController` | `profiles` (type 1) | **NEW** |
| Finance reports | 4 | `TransactionController` | `package_*`, `calendar` | Pass |
| Attendance reports | 4 | `EmployeeAttendanceReportController` | `attendance_logs` | Pass |
| Device sync | 3 | `AjaxController`, `ZkController` | `attendance_logs` | Pass |
| Own password | 2 | `UserController` | `users` | Pass |
| **System users** | 6 | `SystemUserController` | `users`, `roles` | **NEW** |
| **Roles & permissions** | 6 | `RoleController` | `roles`, `permissions` | **NEW** |
| **Audit trail** | 2 | `AuditLogController` | `audit_logs` | **NEW** |
| Legacy HR (dormant) | 25 | various | *tables absent* | Restricted to administrators |

**104 routes** total, all resolving.

---

## 2. Gap analysis

Every candidate was judged against one question: *does this provide meaningful
value to **this** application?*

### Implemented

| Gap | Priority | Why it was genuinely needed |
|---|---|---|
| **Roles & permissions** | **P0** | Authorisation was one hard-coded email (`user@weblinkltd.com`) in `config/access.php` — an account that **did not exist**. All three real logins had unrestricted access to member data, payments and the terminal. No way to limit a receptionist. |
| **System user management** | **P0** | Logins could only be created by hand in SQL. A permission model is useless if roles cannot be assigned. |
| **Executive module** | **P0** | 8 executives existed in the data and drove two attendance reports, but there was **no interface at all** — no list, no create, no edit. Members had full CRUD. The clearest functional asymmetry in the product. |
| **Audit trail** | **P1** | ~1M BDT of payments recorded with no record of who did what. |
| **Member/executive archive** | **P1** | `destroy()` was an empty stub — a member could never be removed. Archiving (not deleting) protects the financial history that reports total. |
| **Duplicate prevention** | **P1** | Found a **real duplicate in the production data** (see §4). |
| **`pin2` unique index** | **P1** | Two profiles sharing a fingerprint PIN silently misattribute attendance. |
| **Reporting indexes** | **P2** | Chosen from observed query patterns, not added speculatively. |
| **Login hardening** | **P1** | Deactivated accounts could still sign in; no session regeneration; no last-login record. |

### Deliberately not implemented

| Considered | Verdict |
|---|---|
| Global search | 72 profiles. Per-screen DataTables search already covers it. Bloat. |
| Import system | No evidence of bulk data entry; members are registered one at a time at a desk. |
| Notification system | No workflow has a recipient or a hand-off. Would produce noise. |
| Approval workflow | No approval step exists in this business. Adding one is bureaucracy. |
| Multi-language / multi-tenancy / chat / AI / real-time | No basis in the product. |
| Forgot-password by email | **Blocked** — no working mail configuration (`MAIL_HOST=mailhog`). An administrator resets passwords from the user editor instead. Documented, not faked. |

---

## 3. New features

### Roles & permissions

**17 permissions** across 5 groups, named `module.action`, defined once in
`App\Support\Permissions` — the single source of truth from which the seeder
builds the table, the role editor renders its matrix, and every Gate registers.

```
members.view/create/update/archive        executives.view/create/update/archive
subscriptions.manage  admissions.manage   reports.finance  reports.attendance
device.sync  users.view/manage  roles.manage  audit.view
```

Enforced three ways, all from the same catalogue: `permission:` route
middleware, `@can` in templates, and `Gate` in controllers. **The sidebar is
generated from the same permissions**, so it can never offer a link the request
would reject.

**Four seeded roles:** Administrator (implicitly holds everything, including
permissions added by future releases — so an upgrade cannot lock the owner out),
Manager, Front Desk, Attendance Viewer.

**Lock-out guards:** an administrator cannot remove their own admin role,
deactivate their own account, or strip the last active administrator. The
Administrator role itself cannot be restricted.

**Migration safety:** `users.role_id` is nullable and every pre-existing account
was assigned Administrator — nobody was silently downgraded from the access they
had yesterday.

### Executives

Full CRUD for `profiles.type = 1`, reusing the member profile form, the same PIN
allocation, and the same graceful degradation when the terminal is offline.
Archiving revokes the fingerprint template and keeps attendance history. Member
IDs are rejected by the executive routes (404) and vice versa.

### Audit trail

Deliberately narrow — money movements, record changes, access changes, sign-in
events. Not a log of every query.

Records user, action, module, record, **only the fields that changed**, IP and
user agent. Passwords and tokens are stripped before writing (test-enforced).
Read-only in the UI: no edit, no delete. Audit failures are logged but never
break the operation being audited.

### Login hardening

Deactivated accounts are signed straight back out; session ID regenerates on
sign-in; last-login timestamp and IP recorded; successful, failed and blocked
attempts audited.

---

## 4. A real defect found in the production data

The `pin2` unique-index migration **refused to run** against the original
production data and named the offenders — exactly as designed.

```
Profiles 505 and 506 — "Tabbassam Zaman Tisha" — both on PIN 508
created 2022-01-23 19:53:17 and 19:53:18
each carrying its own paid 2,000 subscription
23 attendance punches ambiguous between them
```

One second apart, identical details: **a double-submitted registration form**.
The gym has two member records and two ₹2,000 subscriptions for one person.

**Not silently merged.** Which subscription is real, and whether 4,000 was
actually collected, is a business decision. Instead:

* the migration refuses and names the records;
* `php artisan profiles:duplicates` reports PIN clashes and name+phone matches;
* new registrations are blocked when an active member shares a name *and* phone
  number (a name alone is not enough — people share names);
* the UI submit-guard added in the previous pass already prevents the
  double-click that caused this.

For the **test fixture only**, profile 506's duplicate PIN was cleared so the
index could be created. No financial record was touched — both subscriptions
remain intact.

---

## 5. Database changes

All forward-only and additive. No table dropped, no column removed, no data
destroyed. Backed up first to
`wclgym_backup_premigrate_20260827_170756/pre_migrate.sql`.

| Migration | Change |
|---|---|
| `create_roles_and_permissions_tables` | `roles`, `permissions`, `permission_role`; `users.role_id` (nullable FK), `users.is_active`, `users.last_login_at`, `users.last_login_ip` |
| `create_audit_logs_table` | `audit_logs` with indexes on action, module, created_at and `(auditable_type, auditable_id)` |
| `add_integrity_constraints_to_profiles` | **unique** `profiles.pin2`; index `(type, status)`. Refuses to run if duplicates exist |
| `add_reporting_indexes` | `attendance_logs (att_date, pin2)` and `(pin2)`; `package_profile (profile_id, status)` and `(status, ending_date)`; `package_histories (payment_date)` |

Indexes were chosen from the queries the reports actually run, not added
speculatively to every column.

---

## 6. Security improvements

| Issue | Before | After |
|---|---|---|
| **Authorisation** | One hard-coded email; all real accounts unrestricted | 17 permissions enforced per route, per template, per controller |
| **Deactivated accounts** | Could sign in normally | Signed out immediately, attempt audited |
| **Session fixation** | Session ID unchanged across sign-in | Regenerated on sign-in and sign-out |
| **Accountability** | None | Audit trail with user, IP, before/after values |
| **Privilege escalation via forms** | `status` settable through mass assignment? *(No — but `update(['status'=>…])` silently failed for the same reason)* | `status` set explicitly, never mass-assignable |
| **Self-lockout** | n/a | Last-administrator and self-demotion guards |
| **Dormant legacy routes** | Reachable by any signed-in user | Restricted to administrators |
| **Secrets in logs** | n/a | Passwords stripped from audit values (test-enforced) |

A bug worth noting: `status` is deliberately **absent** from `Profile::$fillable`,
so `$member->update(['status' => 0])` silently did nothing — archiving appeared
to work but changed nothing. Caught by a test, fixed by setting the attribute
explicitly rather than by widening mass assignment.

---

## 7. Testing

`php artisan test` — **97 passed, 269 assertions** (was 61).

| Suite | Tests | Covers |
|---|---|---|
| `AuthenticationTest` | 8 | Sign-in, rejection, validation, guest redirects, rate limiting |
| `AuthorizationTest` | 16 | **Rewritten.** Four roles × modules, GET and POST, deactivated and role-less accounts, sidebar matches enforcement |
| `ExecutiveModuleTest` | 10 | CRUD, type isolation, PIN allocation, archive/restore, audit |
| `UserAdministrationTest` | 18 | User CRUD, duplicate email, password rules, role changes taking effect, lock-out guards, audit, no password leakage |
| `ModuleSmokeTest` | 13 | Every sidebar screen |
| `MemberRegistrationTest` | 9 | Registration, money rules, PIN fallback, validation |
| `SubscriptionPaymentTest` | 5 | Payments, renewals, back-dated ledger attachment |
| `ReportCalculationTest` | 6 | Report totals vs independent SQL |
| `MediaTest` / `ZkDeviceServiceTest` | 12 | Upload paths, traversal, offline degradation |

### Manual verification

**33 screens** walked over HTTP as a signed-in administrator against the live
database — **zero failures**, 0.16–0.26 s each. Write flows exercised: member
registration (audited), duplicate re-registration (**correctly refused**),
payment recording, archive/restore.

**Application log: zero errors** during the session.

---

## 8. Known limitations

1. **Forgot-password is not implemented** — no working mail configuration.
   Administrators reset passwords from the user editor.
2. **The ZKTeco terminal is unreachable from this machine**, so device paths are
   verified only in their offline behaviour.
3. **Responsive layout** verified by CSS and served markup, not on real devices.
4. **Legacy HR modules remain dormant** — missing tables, views and mail classes.
   Now administrator-only rather than reachable by everyone.
5. **Production duplicate 505/506 is unresolved by design** — it needs a business
   decision, and the tooling to find it now exists.
6. **One role per user.** For a gym with three staff, many-to-many would be
   over-engineering.

---

## 9. Quality gate

```
[x] Project re-analysed from scratch     [x] Business rules verified
[x] All modules inventoried (14)         [x] Reports verified vs SQL
[x] Every module tested                  [x] UI consistency reviewed
[x] End-to-end workflows tested          [x] Security audited
[x] Database integrity verified          [x] Performance reviewed
[x] Authentication verified              [x] Gap analysis completed
[x] Authorization verified (rebuilt)     [x] P0 + justified P1 implemented
[x] Regression testing (97 tests)        [x] New modules integrated, not bolted on
[x] Application logs reviewed (0 errors) [x] Existing data preserved
[~] Responsive — markup only, no devices [x] Documentation updated
```

---

## 10. Demo login credentials

One account per role, so each level of access can actually be tried. All four
use the password `password`.

| Role | Email | Can reach |
|---|---|---|
| **Administrator** | `admin@cgms.com` | Everything, including users, roles and the audit trail |
| **Manager** | `manager@cgms.com` | Members, executives, all reports, device sync, audit trail — **not** users or roles |
| **Front Desk** | `frontdesk@cgms.com` | Members, subscriptions, admissions, attendance reports, device sync — **not** finance reports, users, roles or audit |
| **Attendance Viewer** | `attendance@cgms.com` | Dashboard and attendance reports only |

Verified over HTTP — each account signed in and every module probed:

```
                     dash  members  execs  finance  attend  device  users  roles  audit
Administrator          y      y       y       y       y       y       y      y      y
Manager                y      y       y       y       y       y       -      -      y
Front Desk             y      y       y       -       y       y       -      -      -
Attendance Viewer      y      -       -       -       y       -       -      -      -
```

Blocked routes return **403**, not a redirect — the restriction is enforced on
the server, not by hiding menu items. The sidebar hides what the account cannot
reach, so each role sees a menu that matches its access.

`DemoDataSeeder` creates these four accounts, so `php artisan db:seed`
reproduces them.

> **These are demo credentials.** Change them before this database is used for
> anything real. An administrator can reset any password from
> *Administration → System Users*.
