Commit b7522599 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(members): unified people search across members and dependents

Overhaul /members/search so one bar finds anyone in the club — the member,
a spouse, a child or a temporary member — and add an advanced filter panel.

MemberSearchService becomes the single source of truth for people search:

- UNIONs members + spouses + children + temporary_members into one normalised
  row per matched PERSON (person_type, relation, parent membership, rank).
- Token AND matching on names, so word order no longer matters:
  "محمود احمد" finds "أحمد سيد محمود".
- Arabic orthographic folding (أ إ آ ٱ→ا, ى→ي, ة→ه, ؤ→و, ئ→ي) applied to both
  the query and the column, so "احمد" matches "أحمد".
- Arabic-Indic and Persian digits folded to ASCII before identifier matching.
- Relevance ranking: exact membership number / national id, then name prefix,
  then substring. LIKE wildcards in user input are escaped.

Scopes (member/spouse/child/temporary) and fields (name, membership number,
national id, phone, form number, passport) are selectable; branch, membership
status and membership type filter on the parent membership. A scope whose table
lacks the requested field is skipped rather than matching nothing.

The legacy search() keeps its exact signature and output shape, so the three
existing API consumers are untouched.

Also:
- Split Member::getStatusOptions() (statuses an employee may ASSIGN) from
  getAllStatusLabels() (every status, for display/filtering). deceased,
  transferred and waived exist in live data but were missing from the list, so
  they could not be filtered on; they are deliberately kept out of the
  assignable set because the Death, Transfer and Waiver workflows own those
  transitions.
- Dependent deep links honour spouse.view / child.view / temp.view and fall
  back to the membership file when denied.
- Map children.relationship (son/daughter) and temporary_members.category
  (nanny/parent/unmarried_daughter) to Arabic for display.
- The search form submitted to /members, dropping most of what was typed; it
  now posts back to /members/search.
- Sidebar declared member.search while the route requires member.view; aligned.

Architecture Map and Dependency Graph updated per project protocol, including
the placeholder-ordering constraint in buildScopeQuery() and the three inline
member-search SQL blocks that remain unconsolidated.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent c39af584
......@@ -65,7 +65,7 @@ class MemberController extends Controller
$members = $db->select("SELECT m.*, b.name_ar as branch_name FROM members m LEFT JOIN branches b ON b.id = m.branch_id WHERE {$where} ORDER BY m.id DESC LIMIT {$perPage} OFFSET {$offset}", $params);
$pagination = \App\Core\Pagination::paginate($total, $perPage, $page);
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Members.Views.index', ['members' => $members, 'branches' => $branches, 'statuses' => Member::getStatusOptions(), 'filters' => $filters, 'pagination' => $pagination]);
return $this->view('Members.Views.index', ['members' => $members, 'branches' => $branches, 'statuses' => Member::getAllStatusLabels(), 'filters' => $filters, 'pagination' => $pagination]);
}
public function create(Request $request): Response
......@@ -1141,8 +1141,56 @@ class MemberController extends Controller
public function search(Request $request): Response
{
$q = trim((string) $request->get('q', ''));
return $this->view('Members.Views.search', ['query' => $q, 'results' => ($q !== '' && mb_strlen($q) >= 2) ? MemberSearchService::search($q, 50) : []]);
$db = App::getInstance()->db();
$q = trim((string) $request->get('q', ''));
$requestedScopes = $request->get('scopes', []);
$requestedScopes = is_array($requestedScopes) ? $requestedScopes : [];
$scopes = array_values(array_intersect(MemberSearchService::SCOPES, $requestedScopes));
$field = (string) $request->get('field', 'all');
if (!in_array($field, MemberSearchService::FIELDS, true)) {
$field = 'all';
}
$filters = [
'q' => $q,
// Empty means "search every scope"; the view renders that as all chips on.
'scopes' => $scopes,
'field' => $field,
'branch_id' => (string) $request->get('branch_id', ''),
'status' => (string) $request->get('status', ''),
'membership_type' => (string) $request->get('membership_type', ''),
];
$results = [];
if ($q !== '' && mb_strlen($q) >= 2) {
$results = MemberSearchService::searchPeople($q, [
'scopes' => $scopes === [] ? MemberSearchService::SCOPES : $scopes,
'field' => $field,
'branch_id' => $filters['branch_id'],
'status' => $filters['status'],
'membership_type' => $filters['membership_type'],
'limit' => 200,
]);
}
// Drives the "advanced search is active" state on the toggle button.
$advancedActive = $scopes !== []
|| $field !== 'all'
|| $filters['branch_id'] !== ''
|| $filters['status'] !== ''
|| $filters['membership_type'] !== '';
return $this->view('Members.Views.search', [
'query' => $q,
'filters' => $filters,
'results' => $results,
'advancedActive' => $advancedActive,
'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"),
'statuses' => Member::getAllStatusLabels(),
'membershipTypes' => Member::getMembershipTypes(),
]);
}
public function changelog(Request $request, string $id): Response
......
......@@ -89,6 +89,13 @@ class Member extends Model
return $colors[$this->status] ?? '#6B7280';
}
/**
* Statuses an employee may assign by hand.
*
* Deliberately EXCLUDES deceased/transferred/waived: those are owned by the
* Death, Transfer and Waiver workflows and must not be settable from a
* dropdown, or the workflow stops being the source of truth.
*/
public static function getStatusOptions(): array
{
return [
......@@ -107,6 +114,19 @@ class Member extends Model
];
}
/**
* Every status that can appear on a member, including the workflow-managed
* ones. Use for DISPLAY and FILTERING only - never as an assignment whitelist.
*/
public static function getAllStatusLabels(): array
{
return self::getStatusOptions() + [
'deceased' => 'متوفى',
'transferred' => 'منقولة',
'waived' => 'متنازل عنها',
];
}
public static function getMembershipTypes(): array
{
return [
......
This diff is collapsed.
......@@ -740,3 +740,47 @@ that advertises it may live in another module's `bootstrap.php`.
`php cli.php permissions:audit` reconciles all four declaration sets and exits
non-zero on drift. Run it after touching any `Routes.php`, any `bootstrap.php`
menu/permission block, or any role seed.
---
## People Search (Members → Spouses / Children / Temporary)
Added when `/members/search` became a cross-entity people search.
### Provider
`App\Modules\Members\Services\MemberSearchService` — single source of truth for
"find a person" inside Members.
### Database dependencies (read-only)
| Table | Columns relied on |
|-------|-------------------|
| `members` | full_name_ar/en, national_id, passport_number, membership_number, form_number, phone_mobile, status, membership_type, branch_id, is_archived |
| `spouses` | full_name_ar/en, national_id, passport_number, mobile, spouse_order, status, member_id, is_archived |
| `children` | full_name_ar/en, national_id, passport_number, birth_certificate_number, relationship, status, member_id, is_archived |
| `temporary_members` | full_name_ar/en, national_id, passport_number, category, status, member_id, is_archived |
| `branches` | id, name_ar, is_active |
**Cascading risk:** renaming or dropping ANY column above breaks the UNION at runtime,
not at boot. The four SELECTs must keep an identical column count and order — adding a
projected column to one scope requires adding it to all four.
### Consumers
| Consumer | Coupling |
|----------|----------|
| `/members/search` screen + `Members.Views.search` | Full row shape incl. `person_type`, `relation_label`, `match_rank` |
| `POST /api/members/search` | Legacy `search()` shape only |
| Spouses / Children / Temporary modules | Search deep-links into their `show` routes and honours `spouse.view`, `child.view`, `temp.view`; when denied it falls back to the membership file |
### Permission dependencies
Route and sidebar entry both gate on `member.search` (see the Authorization section —
these two must be changed together). Dependent deep links additionally require
`spouse.view` / `child.view` / `temp.view`; the view degrades to the membership file
rather than rendering a link that 403s.
### Configuration dependencies
None. No rule-engine keys, no settings — behaviour is entirely code-defined.
### Known divergence
`MemberController@index`, `MemberApiController@searchGet` and `MemberApiV1Controller@search`
still hold their own member-search SQL. A field added to `MemberSearchService` does NOT
appear in those three paths.
......@@ -51,7 +51,7 @@ app/Modules/Members/
│ ├── FormFeeService.php — Form fee calculations
│ ├── FormNumberGenerator.php — Form number sequencing
│ ├── MemberNumberGenerator.php — Membership number + form number assignment
│ ├── MemberSearchService.php — Unified search across members/dependents
│ ├── MemberSearchService.php — Unified PEOPLE search (members + spouses + children + temporary)
│ ├── MembershipPaymentGuard.php — SOLE AUTHORITY for activation/deactivation
│ ├── MembershipRulesService.php — All business rules (fees, eligibility, penalties)
│ ├── MembershipValidationService.php — Validate membership for access (active + subscription paid)
......@@ -129,6 +129,74 @@ app/Modules/Members/
---
## People Search Subsystem
`MemberSearchService` is the single source of truth for "find a person" inside Members.
### API
| Method | Purpose |
|--------|---------|
| `searchPeople(string $q, array $opts): array` | Primary engine. One row per matched PERSON across 4 tables. |
| `search(string $q, int $limit): array` | Legacy member-only wrapper. Delegates to `searchPeople` with `scopes=['member']` and remaps to the old column names. Kept for API consumers — do not change its output shape. |
| `relationLabel(string $type, ?string $raw): string` | Arabic label for a stored relationship value. |
| `normalize()` / `normalizeDigits()` | Arabic orthographic + digit folding. |
### How it works
- UNIONs `members`, `spouses`, `children`, `temporary_members` into one normalised
projection (`person_type, person_id, person_name_ar, …, member_id,
membership_number, member_name_ar, member_status, branch_name, match_rank`).
- **Token AND matching** on names: the query is split on whitespace and every token
must appear, so word order does not matter ("محمود احمد" finds "أحمد سيد محمود").
- **Arabic folding**: أ/إ/آ/ٱ→ا, ى→ي, ة→ه, ؤ→و, ئ→ي applied to BOTH the query (PHP)
and the column (nested SQL `REPLACE`), so "احمد" matches "أحمد".
- **Digit folding**: Arabic-Indic and Persian digits → ASCII before identifier matching.
- **`match_rank`**: 0 = exact membership number / national id, 1 = name prefix, 2 = substring.
- LIKE wildcards in user input are escaped.
### Scopes and fields
- Scopes: `member`, `spouse`, `child`, `temporary`. Empty = all.
- Fields: `all`, `name`, `membership_number`, `national_id`, `phone`, `form_number`, `passport`.
- A scope whose table lacks the requested field is skipped (children/temporary have no
phone column; only members have `form_number` / `membership_number`).
- Membership-level filters (`branch_id`, `status`, `membership_type`) always apply to the
PARENT member, so a child result respects its membership's branch.
### Placeholder ordering (fragile)
Per scope the params are appended in this exact order: **rank params (SELECT list) →
filter params (WHERE) → match params**. Changing the SELECT/WHERE order without
reordering `array_merge` in `buildScopeQuery()` silently mis-binds every row.
### Consumers
| Consumer | Uses |
|----------|------|
| `MemberController@search` (`/members/search`, gated on `member.search`) | `searchPeople()` + all filters |
| `MemberApiController@search` (POST `/api/members/search`) | legacy `search()` |
| `MemberApiController@searchGet` (GET `/api/members/search`) | own inline SQL — NOT yet consolidated |
| `MemberApiV1Controller@search` (`/api/v1/members/search`) | own inline SQL — NOT yet consolidated |
| `MemberController@index` (`/members`) | own inline SQL — NOT yet consolidated |
**Technical debt:** three inline member-search SQL blocks still exist outside the service
and have drifted (5 / 3 / 4 searchable fields vs the service's 6). Fold them into
`searchPeople()` when next touched.
### Status vocabulary (live DB)
- `members.status`: active, deceased, payment_pending, pending_cheques, potential,
transferred, under_review, waived.
- Dependents use a DIFFERENT vocabulary: spouses (active, archived, divorced, inactive,
pending_payment, transferred), children (active, archived, deceased, frozen,
pending_payment, separated), temporary (active, inactive, pending_payment).
Note `pending_payment` (dependents) vs `payment_pending` (members) — not interchangeable.
- `Member::getStatusOptions()` = statuses an employee may ASSIGN by hand. It deliberately
excludes deceased/transferred/waived, which belong to the Death, Transfer and Waiver
workflows. `Member::getAllStatusLabels()` = every status, for DISPLAY and FILTERING only.
Never use `getAllStatusLabels()` as an assignment whitelist.
### Stored relationship values
`children.relationship` = son | daughter. `temporary_members.category` = nanny | parent |
unmarried_daughter. Both are stored in English and mapped to Arabic by `relationLabel()`.
---
## Status Flow (Member Lifecycle)
```
......@@ -366,6 +434,7 @@ dropped → active (within 1 year + board approval + payment)
## Technical Debt
- No test coverage (no test framework configured)
- Three duplicate member-search SQL blocks outside `MemberSearchService` (see People Search Subsystem)
- BillingService has 800 lines with significant duplication across membership types
- MemberController::show() loads 20+ queries per page view
- reconcile() on every page view is a hidden migration — should be event-driven
......
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