# Phase 1 — Identity & Organization

## Goal

The system can identify every human exactly once, organize them into tenants and branches, and enforce granular access control over every action.

---

## Module: Organization (Tenant Root)

### Entity: `organizations` (currently `academies`)

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | Route key |
| name | string | Required |
| name_ar | string | Required |
| slug | string UNIQUE | URL-safe, auto-generated |
| email | string | Nullable |
| phone | string(20) | Nullable |
| logo_path | string | Nullable |
| currency | string(3) | Default 'EGP' |
| timezone | string | Default 'Africa/Cairo' |
| locale | string(5) | Default 'ar' |
| country_code | string(2) | Default 'EG' |
| tax_number | string | Nullable (for future tax invoices) |
| address | text | Nullable |
| settings | jsonb | Default '{}' — org-level config overrides |
| subscription_plan | string(20) | CHECK: trial, starter, professional, enterprise |
| subscription_expires_at | timestamp | Nullable |
| status | string(20) | CHECK: active, suspended, trial, cancelled |
| created_at | timestamp | |
| updated_at | timestamp | |
| deleted_at | timestamp | Soft delete |

### Organization Settings (stored in `settings` JSONB)
- `fiscal_year_start` — month (1-12), default 1
- `week_start_day` — 0-6 (Sunday=0), default 6 (Saturday for Egypt)
- `working_days` — array of day numbers
- `grace_period_minutes` — for attendance late threshold
- `max_payment_overdue_days` — before auto-suspend
- `require_national_id` — boolean, default true
- `allow_partial_payments` — boolean, default true
- `require_approval_for_refund` — boolean, default true
- `refund_approval_threshold` — integer (piasters), above this needs approval
- `auto_generate_sessions` — boolean, default true
- `attendance_auto_absent_minutes` — minutes after session start to auto-mark absent

### Edge Cases
- Organization slug must be globally unique (used in URLs if multi-domain)
- Suspended organizations: users can still LOGIN (to see data) but cannot CREATE anything
- Trial organizations: all features available, time-limited, watermark on exports
- Deleted organizations: soft-deleted, data preserved for 90 days, then eligible for permanent purge

---

## Module: Branch Management

### Entity: `branches`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | CASCADE delete |
| name | string | Required |
| name_ar | string | Required |
| code | string(10) | Unique per academy |
| phone | string(20) | Nullable |
| email | string | Nullable |
| address | text | Nullable |
| city | string | Nullable |
| latitude | decimal(10,7) | Nullable |
| longitude | decimal(10,7) | Nullable |
| manager_id | FK users | Nullable |
| is_main | boolean | Default false — one branch per academy is "main" |
| operating_hours | jsonb | Default '{}' — per-day open/close times |
| status | string(20) | CHECK: active, inactive, closed |
| created_at | timestamp | |
| updated_at | timestamp | |
| deleted_at | timestamp | |

### Business Rules
- Every organization has at least one branch (auto-created on org creation)
- One branch is marked `is_main` — fallback for unassigned entities
- Closing a branch: must reassign or archive all groups, schedules, inventory
- Branch-specific settings override org settings (grace period, operating hours)
- Users can be assigned to one or many branches

### Entity: `branch_user` (pivot)

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| branch_id | FK branches | |
| user_id | FK users | |
| is_primary | boolean | Default false — one primary branch per user |
| created_at | timestamp | |

---

## Module: Person Registry (Single Identity)

### Entity: `people`

The ONE place every human exists. Before they become a participant, trainer, or employee — they are a Person.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | CASCADE |
| national_id | string(14) | Nullable, unique per academy |
| first_name | string | Required |
| last_name | string | Required |
| first_name_ar | string | Nullable |
| last_name_ar | string | Nullable |
| email | string | Nullable |
| phone | string(20) | Nullable |
| phone_secondary | string(20) | Nullable |
| date_of_birth | date | Nullable (derived from national_id if available) |
| gender | string(10) | CHECK: male, female. Derived from national_id if available |
| blood_type | string(5) | Nullable — A+, A-, B+, B-, AB+, AB-, O+, O- |
| nationality | string(50) | Default 'Egyptian' |
| address | text | Nullable |
| city | string | Nullable |
| governorate | string | Nullable (derived from national_id) |
| photo_path | string | Nullable |
| medical_notes | text | Nullable — allergies, conditions, medications |
| notes | text | Nullable — internal notes |
| metadata | jsonb | Default '{}' — extensible custom fields |
| is_active | boolean | Default true |
| created_by | FK users | Nullable |
| updated_by | FK users | Nullable |
| created_at | timestamp | |
| updated_at | timestamp | |
| deleted_at | timestamp | |

### National ID Processing

**Egyptian National ID Format: `CYYMMDDSSSSRG`**
- C: Century (2 = 1900-1999, 3 = 2000-2099)
- YY: Year of birth
- MM: Month of birth
- DD: Day of birth
- SSSS: Sequential number within same day
- R: Registration governorate code
- G: Gender digit (odd = male, even = female)

**Service: `NationalIdService`**
```
parse(string $id): NationalIdResult
validate(string $id): bool
extractDateOfBirth(string $id): Carbon
extractGender(string $id): string
extractGovernorate(string $id): string
calculateAge(string $id): int
```

**Governorate codes (R digit mapping):**
01=Cairo, 02=Alexandria, 03=Port Said, 04=Suez, 11=Damietta, 12=Dakahlia, 13=Sharqia, 14=Qalyubia, 15=Kafr El-Sheikh, 16=Gharbia, 17=Monufia, 18=Beheira, 19=Ismailia, 21=Giza, 22=Beni Suef, 23=Fayoum, 24=Minya, 25=Asyut, 26=Sohag, 27=Qena, 28=Aswan, 29=Luxor, 31=Red Sea, 32=New Valley, 33=Matrouh, 34=North Sinai, 35=South Sinai, 88=Foreign

**Edge Cases:**
- National ID can be null (foreign participants, very young children without ID yet)
- When national ID is entered → auto-fill DOB, gender, governorate (user can override ONLY if ID is null)
- If DOB from ID contradicts manual entry → ID wins with warning
- Duplicate national ID within same academy = BLOCKED
- Same person across academies = ALLOWED (they're different tenants)
- Age recalculates automatically on each access (not stored — derived from DOB)

### Entity: `contacts` (polymorphic)

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| contactable_type | string | Polymorphic |
| contactable_id | bigint | Polymorphic |
| type | string(20) | CHECK: phone, email, whatsapp, address, emergency |
| label | string | Nullable — "Home", "Work", "Mother's phone" |
| value | string | Required |
| is_primary | boolean | Default false |
| is_verified | boolean | Default false |
| created_at | timestamp | |

### Entity: `emergency_contacts`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| person_id | FK people | CASCADE |
| name | string | Required |
| relationship | string(30) | Required — father, mother, brother, sister, spouse, other |
| phone | string(20) | Required |
| phone_secondary | string(20) | Nullable |
| notes | text | Nullable |
| priority | integer | Default 1 — call order |
| created_at | timestamp | |
| updated_at | timestamp | |

---

## Module: Family & Guardian Relationships

### Entity: `guardians`

A Guardian is a Person who is responsible for one or more Participants (usually children).

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| person_id | FK people | UNIQUE — one guardian record per person |
| academy_id | FK organizations | |
| occupation | string | Nullable |
| workplace | string | Nullable |
| can_pickup | boolean | Default true |
| financial_responsibility | boolean | Default true — are they the bill payer? |
| receives_notifications | boolean | Default true |
| receives_reports | boolean | Default true |
| notes | text | Nullable |
| created_at | timestamp | |
| updated_at | timestamp | |

### Entity: `guardian_participant` (pivot — who guards whom)

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| guardian_id | FK guardians | |
| participant_id | FK participants | |
| relationship | string(30) | CHECK: father, mother, brother, sister, uncle, aunt, grandfather, grandmother, guardian, other |
| is_primary | boolean | Default false — primary guardian gets notifications first |
| can_pickup | boolean | Default true |
| can_authorize_medical | boolean | Default false |
| created_at | timestamp | |

### Family Discount Logic
- System auto-detects siblings (participants sharing the same guardian)
- Pricing engine uses `family_size` derived from guardian-participant links
- First child: full price, second child: X% off, third: Y% off (configurable via pricing rules)

### Edge Cases
- One participant can have multiple guardians (divorced parents, grandparents)
- One guardian can have participants in DIFFERENT academies (if we ever go multi-tenant public)
- Guardian can also BE a participant (adult program) — handled via Person → multiple roles
- Financial responsibility can be split (father pays program, mother pays equipment)
- Pickup authorization: list of people allowed to pick up a child (legal requirement in some cases)

---

## Module: User Accounts & Authentication

### Entity: `users` (already exists — refined)

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | CASCADE |
| person_id | FK people | Nullable — links to person registry |
| email | string UNIQUE | Required for login |
| phone | string(20) | Nullable — for SMS login (future) |
| password | string | Hashed |
| is_super_admin | boolean | Default false — platform-level god mode |
| preferred_locale | string(5) | Default 'ar' |
| preferred_timezone | string | Default from academy |
| avatar_path | string | Nullable |
| last_login_at | timestamp | Nullable |
| last_login_ip | string(45) | Nullable |
| login_count | integer | Default 0 |
| force_password_change | boolean | Default false — set on first invite |
| email_verified_at | timestamp | Nullable |
| phone_verified_at | timestamp | Nullable |
| status | string(20) | CHECK: active, inactive, suspended, pending, locked |
| locked_at | timestamp | Nullable — set after N failed attempts |
| lock_reason | string | Nullable |
| remember_token | string | |
| created_at | timestamp | |
| updated_at | timestamp | |
| deleted_at | timestamp | |

### Authentication Rules
- Login by email + password (primary)
- Login by phone + OTP (future, SMS module)
- Failed login threshold: 5 attempts → lock for 30 min (configurable)
- Password policy: minimum 8 chars (configurable per org)
- Force password change on first login (when invited)
- Session timeout: configurable (default 120 min)
- Concurrent session limit: configurable (default unlimited)
- Remember me: 30-day token

### Login History: `login_attempts`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| user_id | FK users | Nullable (failed attempts may not match a user) |
| email | string | The email attempted |
| ip_address | string(45) | |
| user_agent | text | |
| success | boolean | |
| failure_reason | string | Nullable — wrong_password, account_locked, account_suspended |
| created_at | timestamp | |

---

## Module: Roles & Permissions (Full RBAC + ABAC)

This is the ACCESS CONTROL MATRIX you asked about.

### Design Philosophy

The system uses a **hybrid RBAC + ABAC** model:
- **RBAC** (Role-Based): Roles group permissions. Users have roles. Simple and auditable.
- **ABAC** (Attribute-Based): Some permissions have SCOPE constraints based on attributes (own records, own branch, own academy).

### Entity: `roles`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | Nullable — null = system role |
| name | string | Required — unique per academy |
| name_ar | string | Required |
| slug | string | Unique per academy |
| description | text | Nullable |
| is_system | boolean | Default false — system roles can't be deleted |
| is_default | boolean | Default false — auto-assigned to new users |
| level | integer | Default 0 — hierarchy level for escalation |
| created_at | timestamp | |
| updated_at | timestamp | |

### System Roles (seeded, immutable)
| Role | Level | Description |
|------|-------|-------------|
| super_admin | 100 | Platform owner — sees everything across all tenants |
| academy_owner | 90 | Full access to their academy |
| branch_manager | 70 | Full access to their branch(es) |
| finance_manager | 60 | Full financial access, limited ops |
| head_trainer | 50 | Training management + their groups |
| trainer | 40 | Their own groups/sessions only |
| receptionist | 30 | POS, registration, attendance |
| data_entry | 20 | Create/edit limited entities |
| viewer | 10 | Read-only access |
| parent | 5 | Self-service: view child's data, pay invoices |

### Custom Roles
- Academies can create custom roles beyond system roles
- Custom roles compose permissions freely
- Custom roles cannot exceed the creator's level (no privilege escalation)

### Entity: `permissions`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| module | string(30) | e.g., financial, training, people, facility, inventory |
| action | string(30) | e.g., view, create, edit, delete, approve, export, import |
| scope | string(20) | CHECK: own, branch, academy, all |
| name | string | Computed: `{module}.{action}.{scope}` |
| description | string | Human-readable |
| description_ar | string | |
| created_at | timestamp | |

### Permission Matrix (Examples)

| Module | Action | Scope: own | Scope: branch | Scope: academy |
|--------|--------|-----------|--------------|----------------|
| financial.invoices | view | See own invoices | See branch invoices | See all invoices |
| financial.invoices | create | Create invoices | — | — |
| financial.invoices | edit | Edit own draft invoices | Edit branch drafts | Edit any draft |
| financial.invoices | delete | Delete own drafts | Delete branch drafts | Delete any draft |
| financial.invoices | approve | — | Approve branch invoices | Approve all |
| financial.payments | record | Record for own invoices | Record for branch | Record any |
| financial.refunds | create | — | — | Create refunds |
| financial.refunds | approve | — | — | Approve refunds |
| people.participants | view | See own children (parent) | See branch participants | See all |
| people.participants | create | — | Create in own branch | Create anywhere |
| people.participants | edit | Edit own profile | Edit branch participants | Edit all |
| training.programs | view | See enrolled programs | See branch programs | See all |
| training.programs | create | — | — | Create programs |
| training.groups | view | See own groups (trainer) | See branch groups | See all |
| training.sessions | attendance | Mark own group | Mark branch groups | Mark any |
| facility.facilities | view | — | See branch facilities | See all |
| facility.reservations | create | — | Reserve in branch | Reserve any |
| inventory.products | view | — | See branch stock | See all stock |
| inventory.movements | create | — | Move within branch | Move anywhere |
| settings.roles | manage | — | — | Academy owner only |
| settings.users | invite | — | Invite to branch | Invite anywhere |
| reports.financial | view | Own activity | Branch reports | Full reports |
| audit.logs | view | — | — | Academy owner only |

### Entity: `role_permission` (pivot)

| Column | Type |
|--------|------|
| role_id | FK roles |
| permission_id | FK permissions |

### Entity: `role_user` (pivot)

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| role_id | FK roles | |
| user_id | FK users | |
| branch_id | FK branches | Nullable — scope this role to a specific branch |
| granted_by | FK users | Who assigned this role |
| granted_at | timestamp | |
| expires_at | timestamp | Nullable — temporary roles |

### Permission Resolution Algorithm

```
1. Collect all roles for user (including branch-scoped roles)
2. For each role, collect all permissions
3. Merge permissions — most permissive scope wins
4. If action requested:
   a. Find matching permission (module + action)
   b. Check scope:
      - "all" → allow
      - "academy" → allow if same academy_id
      - "branch" → allow if resource.branch_id in user's branches
      - "own" → allow if resource.created_by == user.id OR resource.person_id == user.person_id
   c. If no matching permission → deny
5. SuperAdmin bypasses everything
```

### Edge Cases
- User with NO roles → can only access their own profile
- User with EXPIRED role → role ignored (as if removed)
- User with CONFLICTING permissions (viewer + editor on same module) → most permissive wins
- Branch-scoped role: user has "finance_manager" only for "Downtown Branch" → can manage finances there but not at other branches
- Temporary roles: "Give receptionist access for today while Sarah is sick" → auto-expires
- Role inheritance: NOT supported (keep it flat — predictable > clever)
- Permission delegation: academy_owner can grant any permission at or below their level. Branch_manager can only grant permissions at or below their level within their branch.

### Impersonation (SuperAdmin only)
- SuperAdmin can "become" any user in any academy
- During impersonation: all actions logged as "SuperAdmin via User X"
- Banner shows impersonation state with "exit" button
- Impersonation respects the target user's permissions (debugging experience)
- Impersonation session auto-expires after 60 min

---

## Module: Staff Records

### Entity: `employees`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| person_id | FK people | UNIQUE per academy |
| user_id | FK users | Nullable — not all employees need login |
| employee_number | string(20) | Unique per academy, auto-generated |
| department | string(30) | Nullable |
| position | string(50) | Nullable |
| employment_type | string(20) | CHECK: full_time, part_time, contract, intern, volunteer |
| start_date | date | Required |
| end_date | date | Nullable — null = still employed |
| salary_amount | bigint | Nullable — piasters |
| salary_frequency | string(20) | CHECK: monthly, biweekly, weekly, daily, hourly |
| working_hours_per_week | decimal(4,1) | Nullable |
| branch_id | FK branches | Nullable — primary branch |
| manager_id | FK employees | Nullable — reporting line |
| status | string(20) | CHECK: active, on_leave, suspended, terminated, resigned |
| termination_reason | text | Nullable |
| notes | text | Nullable |
| metadata | jsonb | Default '{}' |
| created_by | FK users | |
| updated_by | FK users | |
| created_at | timestamp | |
| updated_at | timestamp | |
| deleted_at | timestamp | |

---

## Module: Trainer Records

### Entity: `trainers`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| employee_id | FK employees | UNIQUE — every trainer is an employee |
| trainer_number | string(20) | Unique per academy |
| bio | text | Nullable — public-facing bio |
| bio_ar | text | Nullable |
| specializations | jsonb | Default '[]' — array of strings |
| sports | jsonb | Default '[]' — activity IDs they can teach |
| max_daily_sessions | integer | Nullable — fatigue limit |
| max_weekly_hours | decimal(4,1) | Nullable |
| compensation_model | string(20) | CHECK: salary, hourly, per_session, per_group, per_player, revenue_share, contract, hybrid |
| hourly_rate | bigint | Nullable — piasters |
| session_rate | bigint | Nullable |
| group_rate | bigint | Nullable |
| player_rate | bigint | Nullable |
| revenue_share_percent | decimal(5,2) | Nullable |
| rating | decimal(3,2) | Nullable — 1.00 to 5.00 |
| total_sessions_conducted | integer | Default 0 — denormalized counter |
| status | string(20) | CHECK: active, inactive, on_leave, suspended |
| created_at | timestamp | |
| updated_at | timestamp | |

### Entity: `trainer_qualifications`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| trainer_id | FK trainers | |
| name | string | Required — "AFC C License", "Lifeguard Cert" |
| issuer | string | Nullable — issuing body |
| issue_date | date | Nullable |
| expiry_date | date | Nullable — null = no expiry |
| document_path | string | Nullable — uploaded cert |
| is_verified | boolean | Default false |
| verified_by | FK users | Nullable |
| verified_at | timestamp | Nullable |
| created_at | timestamp | |

### Entity: `trainer_availability`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| trainer_id | FK trainers | |
| type | string(20) | CHECK: available, unavailable, preferred |
| day_of_week | integer | 0-6, nullable (null = specific date) |
| specific_date | date | Nullable (null = recurring) |
| start_time | time | |
| end_time | time | |
| reason | string | Nullable |
| created_at | timestamp | |

---

## Deliverable Checklist

After this phase is complete:

- [ ] Any person is registered exactly once
- [ ] National ID auto-derives DOB, gender, governorate
- [ ] Duplicate national IDs are blocked within an academy
- [ ] Users can log in with email + password
- [ ] Failed logins are tracked and lock after threshold
- [ ] Roles are assigned to users (potentially branch-scoped)
- [ ] Every permission check resolves: module × action × scope
- [ ] SuperAdmin can impersonate any user
- [ ] Branch scoping works for all queries
- [ ] Guardians link to participants with relationship types
- [ ] Family discount eligibility is derivable from guardian links
- [ ] Trainers have qualifications, availability, and compensation models
- [ ] All entities have audit trail (created_by, updated_by, soft delete)
