﻿# H-Care System — Process Flows, Conditions, Linkages & Fields

This document is the **authoritative reference** for how the H-Care Property Management System works internally. It is written so that AI agents and developers can understand the full runtime behavior: every process flow, gate/condition, table linkage, and field — without needing to read every source file.

Target codebase: `C:\xampp\htdocs\demo\` (PHP 8.3, MySQL/MariaDB `demo` DB, Metronic v8 UI).

---

## 1. Architecture Overview

```
index.php (landing)  →  2 portal buttons: Owner | Backend
        │
        ├── OWNER PORTAL  (owner/*, 9 pages)   session: portal='owner'
        │
        └── BACKEND PORTAL (backend/*, 30 pages)  session: portal='backend',
                                          user_type='employee' | 'contractor'
                          │
                          ▼
             includes/*.php (shared): config.php → db.php, functions.php,
             auth.php, statuses.php, backend-header.php, chat-drawer.php
                          │
                          ▼
                  api/*.php (20 endpoints, JSON)
                          │
                          ▼
                       MySQL `demo` DB
```

- **Every page** (owner or backend) `require_once config.php` → boots `session_start()`, loads `functions.php`, `db.php`, `auth.php`, `statuses.php`.
- **Every API endpoint** sets `define('IS_API', true)` before loading config, so session-timeout redirect logic is skipped (`refreshSessionTimeout()` returns early for APIs).
- **Pages** use server-side rendering + jQuery/AJAX against the API layer. The chat drawer is embedded via `backend-footer.php` / `owner-footer.php`.

---

## 2. Session Model & Auth Functions

### 2.1 Session keys (source of truth: `includes/auth.php`)

| Key | Owner | Backend-Employee | Backend-Contractor |
|-----|-------|------------------|---------------------|
| `portal` | `'owner'` | `'backend'` | `'backend'` |
| `user_type` | _(absent)_ | `'employee'` | `'contractor'` |
| `user_id` | owner_id (e.g. `OW0001`) | employee_id (e.g. `EMP0001`) | contractor_id (e.g. `CON0001`) |
| `user_name` | owner_name | employee_name | company_name |
| `role` | _(absent)_ | `superadmin`/`inspector`/`worker` | _(absent)_ |
| `employee` | _(absent)_ | full employee row | _(absent)_ |
| `contractor` | _(absent)_ | _(absent)_ | full contractor row incl. `trades[]` |
| `last_activity` | timestamp | timestamp | timestamp |
| `csrf_token` | set lazily by `csrf_token()` | same | same |

### 2.2 Auth function contract

| Function | Returns / Behavior |
|----------|-------------------|
| `isOwnerLoggedIn()` | `portal === 'owner'` |
| `isBackendLoggedIn()` | `portal === 'backend'` |
| `isEmployeeUser()` | `isBackendLoggedIn() && user_type === 'employee'` |
| `isContractorUser()` | `isBackendLoggedIn() && user_type === 'contractor'` |
| `isEmployeeLoggedIn()` | backward-compat: `portal==='employee'` OR `user_type==='employee'` |
| `isContractorLoggedIn()` | backward-compat: `portal==='contractor'` OR `user_type==='contractor'` |
| `requireOwnerAuth()` | redirect to `owner/login.php` if not owner |
| `requireBackendAuth()` | redirect to `backend/login.php` if not backend |
| `requireEmployeeAuth()` | `requireBackendAuth()` + must be `isEmployeeUser()` else redirect login |
| `requireContractorAuth()` | `requireBackendAuth()` + must be `isContractorUser()` else redirect login |
| `requireBackendEmployee()` | 403 JSON if not employee (API usage) |
| `requireBackendContractor()` | 403 JSON if not contractor (API usage) |
| `requirePermission($p)` | Backend + employee required; superadmin bypass; else check `hasPermission`; on fail → JSON 403 if API, else redirect `backend/dashboard.php` |
| `requireApiPermission($p)` | 401 if not backend employee; superadmin bypass; else 403 JSON |
| `hasPermission($p)` | superadmin `true`; else `SELECT 1 FROM role_permissions WHERE role=? AND permission=?` |
| `getCurrentUserId()` | `$_SESSION['user_id']` or null |
| `getCurrentUserType()` | `'owner'` if owner portal, else `user_type` |
| `getCurrentUserRole()` | `role` **only if** `isEmployeeUser()`, else null |
| `getCurrentEmployee()` | row from `employees` **only if** `isEmployeeUser()` |
| `getCurrentContractor()` | row from `contractors` **only if** `isContractorUser()` |
| `refreshSessionTimeout()` | **SKIPS on IS_API**. If `time()-last_activity > SESSION_TIMEOUT` (1800s): capture `portal`, `session_unset()`+`session_destroy()`, redirect owner→`owner/login.php`, backend/employee/contractor→`backend/login.php`. Otherwise refresh `last_activity`. |
| `logout()` | session destroy + redirect `BASE_URL` |
| `autoExpireTenancies()` | See §6.4 |

### 2.3 Backward-compat note
`isEmployeeLoggedIn()` / `isContractorLoggedIn()` are kept for API endpoints during transition. They match **either** old portal values (`employee`/`contractor`) **or** the new `user_type`. After full migration, prefer the new functions.

---

## 3. Login Flows

### 3.1 Backend login (`backend/login.php`, `POST` to same page)

1. If already `isBackendLoggedIn()` → redirect `backend/dashboard.php`.
2. Read `user_id` + `password`.
3. **Query `employees`** by `employee_id` **AND `status='Active'`**.
   - Found → verify password (bcrypt `password_verify` first; fallback `hash('sha256',$pw)==stored`, and on match auto-migrate stored hash to bcrypt). On success set session: `portal='backend'`, `user_type='employee'`, `user_id`, `user_name`, `role`, `employee`, `last_activity` → redirect `backend/dashboard.php`.
   - Not found (or inactive) → **fall through to contractors**.
4. **Query `contractors`** by `contractor_id` **AND `status='Active'`**. Same password verification. On success set session: `portal='backend'`, `user_type='contractor'`, `user_id`, `user_name`, `contractor` (+ `trades` array from `contractor_trades`), `last_activity` → redirect `backend/dashboard.php`.
5. Neither → error: *"Invalid User ID or password. If your account has been deactivated, please contact the administrator."*

**Key condition:** employee check runs first; a contractor ID is never checked against `employees` table and vice-versa. IDs use distinct prefixes (`EMP*`/`CON*`).

### 3.2 Owner login (`owner/login.php`)

- Query `owners` by `owner_id` AND `status='Active'`.
- Same password verification + SHA-256 auto-migration.
- **Conditional redirect:** if `first_login == 1` → redirect `owner/change-password.php` (forces password change). Else → `owner/dashboard.php`.
- Session: `portal='owner'`, `user_id`, `user_name`, `last_activity`.

### 3.3 Logout

- `backend/logout.php` and `owner/logout.php`: `session_unset()`, `session_destroy()`, redirect to respective login page.
- API `logout` action in `api/auth.php`: destroys session, returns JSON success (no redirect).

### 3.4 API login (`api/auth.php` — POST JSON)

| Action | Behavior |
|--------|----------|
| `owner-login` | Body: `owner_id`,`password`. Sets owner session. Returns owner row (password removed). |
| `employee-login` | Legacy. Sets `portal='backend'`, `user_type='employee'`. Returns employee row. |
| `contractor-login` | Legacy. Sets `portal='backend'`, `user_type='contractor'` + trades. Returns contractor row. |
| `backend-login` | **Preferred.** Body: `user_id`,`password`. Tries employees table first, then contractors. Sets the appropriate backend session. Returns the matched row (password removed). 401 if neither. |
| `change-password` | Uses `getCurrentUserType()` to pick table (owner→`owners`, employee→`employees`, contractor→`contractors`). Verifies `old_password` via `password_verify`, sets `hashPassword(new)`. For owners also sets `first_login=0` + `updated_at`. |
| `logout` | Session destroy, JSON success. |

---

## 4. Portal Pages & URL Routing

### 4.1 Backend portal (`backend/`) — dynamic by `user_type`

| Page | Access gate | Notes |
|------|-------------|-------|
| `login.php` | — | Unified login |
| `logout.php` | — | Destroys session |
| `dashboard.php` | `requireBackendAuth()` | **Renders differently**: employee → property stats/charts/appointments/recent SRs; contractor → active jobs / completed jobs / performance score / recent jobs |
| `jobs.php` | `requireBackendContractor()` | Contractor-only. DataTable of assigned SRs with `start-work` / `mark-done` actions. Employees get redirected. |
| `profile.php` | `requireBackendAuth()` | Employee → avatar upload + profile + change password; Contractor → editable company fields + change password |
| `change-password.php` | `requireBackendAuth()` | Works for both; updates `employees` or `contractors` based on `isEmployeeUser()` |
| `service-requests.php` / `maintenance-requests.php` / `cleaning-requests.php` / `transport-requests.php` / `special-requests.php` | `requirePermission('manage_services')` | All share `_service-requests-shared.php`; `$defaultCategory` sets the category filter |
| `defects.php` | `requirePermission('manage_services')` | Defect list + create/resolve |
| `properties.php`, `property-detail.php` | `requirePermission` (manage_properties / view_all) | Board + unit CRUD |
| `property-calendar.php`, `calendar.php` | `requireBackendAuth()` + employee | FullCalendar views |
| `owners.php`, `tenants.php`, `tenancies.php`, `prospect.php`, `appointment.php`, `maintenance-types.php`, `contractors.php`, `contractor-form.php`, `customer-statements.php`, `customer-statement-form.php`, `users.php`, `announcements.php`, `announcement-form.php` | respective `requirePermission(...)` | Standard CRUD pages |

**Sidebar rendering** (`includes/backend-header.php`):
- `isEmployeeUser()` → full menu (Developments / Operations / Account sections; Customer Statements + System Users additionally permission-gated via `hasPermission`).
- `isContractorUser()` → Dashboard, My Jobs, My Profile, Change Password **only**.
- `autoExpireTenancies()` is called **only** for employees.

### 4.2 Owner portal (`owner/`)

| Page | Purpose |
|------|---------|
| `login.php`, `logout.php`, `dashboard.php` | Auth + overview |
| `profile.php`, `change-password.php` | Profile + password (first-login force) |
| `my-units.php` | Owner's owned units |
| `service-request.php` | Submit new SR + track own SRs with timeline |
| `statements.php` | View customer statements |
| `tenancy.php` | View tenancies on owned units |

### 4.3 Landing page (`index.php`)

- If `isOwnerLoggedIn()` → redirect `owner/dashboard.php`.
- Else if `isBackendLoggedIn()` → redirect `backend/dashboard.php`.
- Else render 2 buttons: **Owner Portal** → `owner/login.php`, **Backend Portal** → `backend/login.php`. (Contractor button removed.)

### 4.4 `.htaccess` URL rewriting

```
RewriteBase /demo/
# Legacy 301 redirects (added at top, run first):
RewriteRule ^employee/(.*)$        /demo/backend/$1        [R=301,L]
RewriteRule ^contractor/dashboard\.php$        /demo/backend/dashboard.php        [R=301,L]
RewriteRule ^contractor/jobs\.php$             /demo/backend/jobs.php             [R=301,L]
RewriteRule ^contractor/profile\.php$          /demo/backend/profile.php          [R=301,L]
RewriteRule ^contractor/change-password\.php$  /demo/backend/change-password.php  [R=301,L]
RewriteRule ^contractor/login\.php$            /demo/backend/login.php            [R=301,L]
RewriteRule ^contractor/logout\.php$           /demo/backend/logout.php           [R=301,L]
RewriteRule ^contractor/(.*)$                  /demo/backend/                     [R=301,L]

# API rewriting (only when file doesn't exist, not already .php):
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !\.php$
RewriteRule ^api/(.+)$ api/$1.php [L,QSA]
```

**Effect:** `/demo/employee/anything` and all legacy `/demo/contractor/*` paths 301-redirect to `/demo/backend/*`. API calls are transparently mapped `/demo/api/chat` → `api/chat.php`.

---

## 5. Shared Helpers (`includes/`)

### 5.1 `functions.php`
- `h($str)` — HTML-escape (all output must pass through this).
- `redirect($url)` — `header('Location: ...'); exit;`
- `asset($path)` — `BASE_URL . 'assets/' . $path`.
- `upload_url($path)` — `BASE_URL . $path` (blank avatar fallback).
- `format_date()/format_datetime()/time_ago()`.
- `json_response($data,$code)`, `json_error($msg,$code=400)`, `json_success($data=null,$msg='Success')` — all `exit` after echo.
- `post($key,$default=null)`, `get($key,$default=null)`.
- `csrf_token()` — lazy-generate `$_SESSION['csrf_token']` (32 random bytes hex).
- `verify_csrf()` — compares `$_POST['csrf_token']` or header `X-CSRF-Token` against session; 403 JSON on mismatch. **Called by every mutating API endpoint.**

### 5.2 `db.php` (PDO helpers — all use `global $pdo`)
- `query($sql,$params=[])` → prepared statement.
- `queryAll` / `queryOne` / `queryValue` — fetch all / one / single column.
- `insert($table,$data)` → returns `lastInsertId()`.
- `update($table,$data,$where,$whereParams=[])` — `$where` string or assoc array.
- `deleteRecord($table,$where,$params=[])`.
- `generateId($prefix,$table,$column)` — e.g. `EMP0001` → next numeric suffix, zero-padded 4.
- `db_count($table,$where='',$params=[])`.
- `lastInsertId()`.

### 5.3 `statuses.php` (single source of truth for dropdowns + badges)

| Function | Returns |
|----------|---------|
| `getMarketingStatuses()` | `SALE`, `RENT`, `SALE_RENT`, `NOT_FOR_SALE`, `NOT_FOR_RENT`, `NOT_FOR_SALE_RENT` |
| `getUnitStatuses()` | `AVAILABLE`, `RESERVED_VIEWING`, `RESERVED_CLOSING`, `BLOCKED_MAINTENANCE`, `BLOCKED_RENOVATION`, `SOLD`, `UNRELEASED` |
| `getUnitCategories()` | `NORMAL`, `BUMI_LOT`, `BUMI_RELEASED`, `CONTRACTOR_UNIT`, `STAFF_UNIT`, `VIP_UNIT`, `DEVELOPER_HOLD`, `MANAGEMENT_HOLD` |
| `getPropertyStatuses()` | `ACTIVE`, `INACTIVE`, `COMPLETED`, `FUTURE_DEVELOPMENT` |
| `getBlockStatuses()` | `ACTIVE`, `UNDER_CONSTRUCTION`, `VP_READY`, `FULLY_SOLD`, `CLOSED` |
| `get_status_badge()` | HTML badge span (handles SR statuses + generic Active/Inactive etc.) |

---

## 6. Core Domain Flows

### 6.1 Property → Block → Unit management (`api/properties.php`, `api/units.php`)

**Linkage chain:** `properties` (PK `property_id`) → `blocks` (FK `property_id`) → `property_units` (FKs `property_id`, `block_id`, `owner_id`).

**Create property** (POST `api/properties.php`, gate `manage_properties`):
- `property_name` required, `status` default `ACTIVE`.
- `property_id` auto `PROP####`.

**Delete property** (DELETE): **blocked** if `db_count('property_units', 'property_id=?') > 0` ("Remove all units first").

**Unit creation** (POST `api/units.php`, gate `manage_properties`):
- Single: `property_id`, `block`, `unit_no`, plus optional `unit_status`/`marketing_status`/`unit_category`/`owner_id`. Validation: statuses/categories must exist in `statuses.php` maps; duplicate check on `(property_id, block, unit_no)`; owner must be Active.
- **`bulk-create` action**: accepts `unit_numbers[]` array **or** `start_number`+`end_number` (+optional `floor`) → auto-generates `{block}-{floor}-%02d` or `{block}-%02d`; pre-checks duplicates; auto-creates `blocks` row if block name missing (block_id synced).
- **Block auto-sync condition:** whenever a unit references a block name that has no `blocks` row, a `blocks` row is created with `block_status='ACTIVE'`.

**Unit update** (PUT): any of `property_id`, `block`, `unit_no`, `unit_status`, `marketing_status`, `unit_category`, `owner_id`.
- **SOLD cascade condition:** if `unit_status` becomes `'SOLD'` and there is an **Active** tenancy on the unit → set tenancy `Inactive` AND set `marketing_status='NOT_FOR_SALE_RENT'`.

**Unit delete** (DELETE): **blocked** if the unit has an Active tenancy.

### 6.2 Owners (`api/owners.php`)

- Gate `manage_owners` for all writes.
- Create → auto `OW####`, **default password `hunza123`** (bcrypt), `first_login` defaults 1.
- GET with `id` returns `owned_units` (units where `owner_id` matches) — this powers the owner portal "My Units".
- DELETE is actually **deactivate**: sets `status='Inactive'` (never hard-deletes, protects FK).

### 6.3 Tenants (`api/tenants.php`)

- Gate `manage_tenants`. Create auto `TN####`.
- GET list includes `active_tenancies` count (subquery on `tenancies.status='Active'`).
- DELETE = deactivate (`status='Inactive'`).
- **Linkage:** tenant referenced by `tenancies.tenant_id`.

### 6.4 Tenancies (`api/tenancies.php` + `autoExpireTenancies`)

**Table:** `tenancies(tenancy_id PK, unit_id FK, tenant_id FK, lease_start, lease_end, lease_duration, rental_amount, handler_employee FK→employees, status, created_at)`.

**Create** (POST, gate `manage_tenancies`):
- Requires `unit_id`, `tenant_id`, `lease_start`, `lease_end`. Tenant must be Active.
- **Conflict condition:** if unit already has an **Active** tenancy → error "Unit already has an active tenancy. Expire it first...".
- `lease_duration` computed as months: `($start->diff($end))->y * 12 + ->m`. Lease end must be after start.
- On success: unit updated to `unit_status='AVAILABLE', marketing_status='NOT_FOR_SALE_RENT'` (i.e., taken off the sale/rent market).

**Expire action** (POST `action=expire`):
- Sets tenancy `Inactive`.
- **Conditional unit unlock:** if no *other* Active tenancy on that unit → unit set `AVAILABLE` + `SALE_RENT`.

**Update** (PUT): same unit-unlock logic when `status` set to `Inactive`.

**Auto-expire** (`autoExpireTenancies()`, called only from `backend-header.php` when employee):
- Finds all Active tenancies with `lease_end < today` → set `Inactive`; if no other Active tenancy on the unit → unit back to `AVAILABLE` / `SALE_RENT`. Returns count expired.

### 6.5 Service Request workflow (`api/service-requests.php`) — **most complex flow**

**Status machine:**

```
Submitted ──approve──▶ Approved ──start──▶ In Progress ──done──▶ Done ──verify──▶ Verified
   │                     │                    │ (by worker/       │                (closed)
   │                     │                    │   contractor)     │
   └──reject──▶ Rejected │                    │                   │
                         └──reject──▶ Rejected└──(contractor: mark-done + invoice)
```

**Valid transitions map (enforced server-side):**
```php
'Submitted'   => ['approve', 'reject'],
'Approved'    => ['start', 'reject'],
'In Progress' => ['done'],
'Done'        => ['verify'],
```

**Two separate POST branches:**

**(a) Employee actions** — `['approve','reject','start','done','verify']`, gate `requireApiPermission('manage_services')`:

| Action | Extra permission condition | Side effects |
|--------|---------------------------|--------------|
| `approve` | superadmin or `hasPermission('approve_requests')` | `status='Approved'`, `approved_at`, `handler_employee` = posted or current user |
| `reject` | superadmin or `approve_requests` | `status='Rejected'`, optional `remark` |
| `start` | superadmin or role `inspector` | `status='In Progress'`, `in_progress_at` |
| `done` | superadmin or `confirm_jobs` | `status='Done'`, `done_at`, optional `photo_attachment` (upload or posted path), `condition_rating` (1–5), `remark` |
| `verify` | superadmin or `verify_jobs` | `status='Verified'`, `verified_at`, optional photo/rating/remark |

**(b) Contractor + manager actions** — `['assign-contractor','unassign-contractor','start-work','mark-done','mark-paid']`:

| Action | Gate | Conditions | Side effects |
|--------|------|-----------|--------------|
| `assign-contractor` | `requireApiPermission('manage_services')` + superadmin/`approve_requests` | `contractor_id` must exist & Active | Sets `contractor_id`. **Chat linkage:** if no `conversations` row of `type='request'` for this request → creates one + `conversation_participants` for owner (`'owner'`), contractor (`'contractor'`), handler employee (`'employee'`). If conversation exists but contractor not yet participant → adds contractor participant. |
| `unassign-contractor` | `manage_services` + superadmin/`approve_requests` | — | `contractor_id = null` |
| `start-work` | `isContractorUser()` | Contractor must be **the assigned** contractor (`existing.contractor_id == current user`) AND status `'Approved'` | `status='In Progress'`, `in_progress_at` |
| `mark-done` | `isContractorUser()` | Assigned contractor + status `'In Progress'` | Requires `invoice_file` (PDF upload via `handlePdfUpload` → `INVOICE_DIR`) and `invoice_amount`; optional `condition_rating`, `photo_attachment`, `remark`. Sets `status='Done'`, `done_at`, `invoice_status='Pending'`. |
| `mark-paid` | `manage_services` + superadmin/`verify_jobs` | `invoice_status === 'Pending'` | `invoice_status='Paid'` |

**PUT branch:** same transition table as employee POST actions, gated `requireApiPermission('manage_services')` + CSRF.

**Creation (owner or employee):** gated on `isOwnerLoggedIn() || isEmployeeUser()`.
- Owner → `requestor_id` forced to current owner id.
- Requires `requestor_id` (valid Active owner), `category`, optional `type_id`.
- **Transport extras** (when `category='Transport'`): `driver_name`, `car_no`, `driver_contact`, `arrival_time`, `departure_time`.
- `request_no` auto: `SR-YYYYMMDD-###` (daily sequence).
- Initial `status='Submitted'`, `submitted_at`=now.

**GET filters:**
- `?id=` → single joined row.
- `?owner_id=` → requests for that owner (optionally filtered by `contractor_id`).
- Default list: all rows; **contractor scoping:** if `isContractorUser()` → forces `contractor_id = current user id` (contractors only ever see their own jobs).
- Joins: `owners` (requestor_name), `maintenance_types` (type_name), `employees` (handler_name), `contractors` (contractor_name).

**Key fields** (`service_requests`): `request_id PK, request_no, requestor_id FK→owners, category, type_id FK→maintenance_types, handler_employee FK→employees, contractor_id FK→contractors, driver_name, car_no, driver_contact, arrival_time, departure_time, photo_attachment, condition_rating, remark, status, submitted_at, approved_at, in_progress_at, done_at, verified_at, updated_at, invoice_file, invoice_amount, invoice_uploaded_at, invoice_status`.

### 6.6 Contractors & performance scoring (`api/contractors.php`)

- Gate `manage_users` for writes (create/update/deactivate/activate/recalculate-score).
- **Create:** `company_name`, `contact_person`, `password` required; optional `trades[]` array — each trade must exist in `maintenance_types.type_name` (FK `contractor_trades.trade`). Auto ID `CON####`.
- **GET filters:** `status`, `trade` (EXISTS subquery on `contractor_trades`), `category` (EXISTS join contractor_trades→maintenance_types where `mt.category=?`). This is how the SR assign modal filters contractors by the request category.
- **Deactivate condition:** **blocked** if contractor has SRs in `('Approved','In Progress')` ("has active job(s)").
- **`recalculate-score`:**
  - `avgRating` = AVG(`condition_rating`) from their SRs (1–5).
  - `avgResponseHours` = AVG(TIMESTAMPDIFF(HOUR, approved_at, in_progress_at)).
  - `score = round((avgRating/5)*3 + max(0, 1 - avgResponseHours/168)*2, 2)` → 0–5 scale.
- **Linkage:** `contractors` ← `contractor_trades` (many-to-many with maintenance_types), `contractors` ← `service_requests.contractor_id`, `contractors` ← `defects.contractor_id`.

### 6.7 Defects (`api/defects.php`)

- Table: `defects(defect_id PK, defect_no, contractor_id FK, request_id FK→service_requests nullable, reported_by FK→employees, title, description, status, created_at, resolved_at, resolved_by, resolution_notes)`.
- `defect_no` auto: `DEF-YYYYMMDD-###`.
- **Create** (gate `manage_services`): `contractor_id` + `title` required; optional `request_id`.
- **GET scoping:** if `isContractorUser()` → only defects for their contractor_id; else optional `?contractor_id=`/`?status=` filters.
- **Resolve** (`action=resolve`, gate `manage_services`): only `status='Open'`; sets `Resolved`, `resolved_at`, `resolved_by`, `resolution_notes`.
- **Update** (`action=update`): only `status='Open'`; editable `title`, `description`.

### 6.8 Appointments (`api/appointments.php`)

- Gate `manage_appointments` for writes.
- **Create:** `unit_id`, `prospect_name`, `sales_person` (must be Active employee), `appointment_type` (`Sell`/`Rent`), `appointment_date`, `appointment_time` required. `appointment_no` auto `APPT####`.
- **`action=outcome`:** validates outcome ∈ `['Interest','Not Interested','Follow Up']`.
- **Calendar linkage:** `api/calendar.php?view=available` lists appointments as 2-hour timed events; default `view` lists unavailable/occupied units as all-day events.

### 6.9 Prospects & Pipeline (`api/prospects.php`, `api/pipeline-stages.php`)

- `prospects` table (with `pipeline_stage_id` INT). Default stage `1` (`New`) on create.
- `pipeline_stages(stage_id PK, stage_name, sort_order, pipeline_type default 'sales')`.
- Prospect `interested_in` ∈ `['Sell','Rent','Both']`.
- Stage actions (`pipeline-stages.php`): `reorder` (bulk sort_order update), `update` (rename), `delete` (**cannot delete stage 1 "New"**), default create (auto next `sort_order`).

### 6.10 Announcements (`api/announcements.php`)

- Gate `manage_users`.
- `announcement_no` auto `ANN{YYYY}####`.
- Fields: `title`, `content`, `published_by` (current user), `start_date`, `end_date`, `target_type` (default `Everyone`), `status`.
- **Targeting linkage:** `announcement_targets(announcement_id FK, property_id FK, target_level, block_id, unit_id)`.
  - `target_level='property'` → rows with `property_id` + level.
  - `'block'` → rows with `property_id` + `block_id`.
  - `'unit'` → rows with `property_id` + `unit_id`.
  - On update, targets are deleted + re-inserted if any of `target_properties`/`target_blocks`/`target_units` posted.

### 6.11 Customer Statements (`api/customer-statements.php`, `api/statement-download.php`)

- Table: `customer_statements(statement_id PK, statement_no, property_id FK, owner_id FK, title, statement_period, description, file_name, file_path, file_size, file_type, uploaded_by FK→employees, viewed_at, created_at)`.
- **Owner GET:** scoped to `owner_id = current owner`.
- **Employee GET/upload:** gate `requireApiPermission('inspect_access')`. Upload accepts `pdf/png/jpg/jpeg` ≤20MB → saved to `uploads/statements/` as `st_*.ext`. `statement_no` auto `ST-YYYYMMDD-###`.
- **Download** (`statement-download.php`): owner may only download own statement; employee needs `inspect_access`; streams file with correct content-type.
- `mark_viewed` PUT: owners mark all own statements viewed; employees mark a specific one.

### 6.12 System Users / Employees (`api/employees.php`)

- Gate `manage_users` for writes.
- **Create:** `employee_name`, `employee_role` ∈ `[superadmin, inspector, worker]`, `password` required. Optional custom `employee_id` (regex `^[A-Za-z0-9]+$`, uniqueness check) else auto `EMP####`. Random avatar from `images/avatars/*.jpg` if none posted.
- **Deactivate:** **hard guard** — `EMP0001` cannot be deactivated.
- Update: editable `employee_name`, `employee_role`, `contact_no`, `email`, `status`, `avatar`, optional `password` (bcrypt).

### 6.13 Avatar upload (`api/upload-avatar.php`)

- Gate `manage_users` + CSRF. `employee_id` required. Accepts `jpg/jpeg/png/gif/webp` → `uploads/avatars/avatar_{id}_{ts}.{ext}`. Updates `employees.avatar`.

### 6.14 Calendar API (`api/calendar.php`)

- GET only. `view=available` → appointment events (2h slots) for **available** units (unit_status AVAILABLE + marketing ∈ SALE/RENT/SALE_RENT).
- Default view → unavailable units (RESERVED_*, BLOCKED_*, UNRELEASED, or AVAILABLE-but-tenanted). Rented units span `lease_start`→`lease_end`; non-tenanted span today→+5y.
- Colors from unit status map.

---

## 7. Chat System (`api/chat.php`, `includes/chat-drawer.php`)

### 7.1 Data model

```
conversations(conversation_id PK, type 'general'|'request', request_id FK→service_requests NULL, created_at)
conversation_participants(conversation_id FK, participant_id, participant_type, last_read_at, PK(conv,id,type))
messages(message_id PK, conversation_id FK, sender_id, sender_type, message, created_at)
```

**`participant_type` / `sender_type` values are exactly:** `'employee'`, `'owner'`, `'contractor'`.

### 7.2 Auth mapping (critical)

`requireChatAuth()`:
1. `userId = getCurrentUserId()`.
2. `portal = $_SESSION['portal']`.
3. If `portal === 'backend'` → **translate to `$_SESSION['user_type']`** (`'employee'` or `'contractor'`). This makes `participant_type` match DB values without migration.
4. If `portal === 'owner'` → `'owner'`.
5. Returns `[$userId, $participantType]`.

All downstream queries use these as `participant_type`/`sender_type`.

### 7.3 Actions

| Method | Action | Behavior |
|--------|--------|----------|
| GET | `list` | Conversations where `participant_id=? AND participant_type=?`; includes last message, participant names (via `resolveParticipantName()`), unread count (messages newer than `last_read_at`, excluding own), `title` = request_no for `type='request'` |
| GET | `messages` | Must be participant (403 otherwise); marks `last_read_at=NOW()`; returns all messages ASC |
| GET | `unread` | Count of unread messages across conversations (badge) |
| GET | `search` | `q` ≥2 chars; conversation matches if any participant name LIKE `%q%` across employees/owners/contractors |
| POST | `send` | Must be participant (403); inserts message with `sender_type=$participantType` |
| POST | `create` | Validates `type` ∈ `[general, request]`, each participant `{id,type}` exists in correct table; creates conversation + current user participant + listed participants |

### 7.4 `resolveParticipantName($id, $type)`
- `employee` → `employees.employee_name`
- `owner` → `owners.owner_name`
- `contractor` → `contractors.company_name`
- default → raw id

### 7.5 Drawer behavior (`chat-drawer.php`)
- Header button `openChatDrawer()` shows drawer, starts 5s polling (`loadConversations`), badge polled once on load (`loadUnreadCount`).
- `isMine = msg.sender_id === currentUserId` (ownership highlight only; type not compared).
- All data via API; no portal knowledge in JS.

---

## 8. File Upload Handling

| Helper | Dir (constant) | Rules |
|--------|----------------|-------|
| `handleFileUpload($field,$dir)` | `SERVICE_PHOTO_DIR`, `AVATAR_DIR` etc. | ext ∈ jpg/jpeg/png/gif/webp/pdf; size ≤10MB (pdf 20MB); `img_`/`pdf_` prefix + uniqid |
| `handlePdfUpload($field,$dir)` | `INVOICE_DIR` | pdf only ≤20MB; `invoice_` prefix |
| avatar upload (profile page) | `AVATAR_DIR` | image ext; `avatar_{id}_{ts}.{ext}` |
| customer statement upload | `STATEMENT_DIR` | pdf/png/jpg/jpeg ≤20MB; `st_{uniqid}.{ext}` |

All stored paths are relative (`uploads/...`) and rendered via `BASE_URL . path`.

---

## 9. Permission Matrix (from `role_permissions` seed)

| Permission | superadmin | inspector | worker |
|-----------|:----------:|:---------:|:------:|
| `admin_access` | ✓ | | |
| `manage_users` | ✓ | | |
| `manage_properties` | ✓ | | |
| `manage_owners` | ✓ | | |
| `manage_tenants` | ✓ | | |
| `manage_tenancies` | ✓ | | |
| `manage_services` | ✓ | ✓ | |
| `manage_maintenance_types` | ✓ | | |
| `manage_appointments` | ✓ | | |
| `manage_prospects` | ✓ | | |
| `view_all` | ✓ | ✓ | |
| `approve_requests` | ✓ | ✓ | |
| `inspect_access` | ✓ | ✓ | |
| `verify_jobs` | ✓ | ✓ | |
| `worker_access` | ✓ | | ✓ |
| `view_assigned` | ✓ | | ✓ |
| `confirm_jobs` | ✓ | | ✓ |

Superadmin (`EMP0001`) has a **hardcoded bypass** (`role === 'superadmin'` returns true in `hasPermission`) and cannot be deactivated.

Contractors have **no `role`** — `getCurrentUserRole()` returns null and `requirePermission()` redirects them to dashboard. They are authorized by `isContractorUser()` checks (e.g., `start-work`, `mark-done`, `jobs.php`, `api/defects.php` scoping).

---

## 10. Key Sequence Diagrams

### 10.1 Service request end-to-end (with contractor)

```mermaid
sequenceDiagram
    participant O as Owner
    participant E as Employee(Inspector)
    participant C as Contractor
    participant DB as MySQL

    O->>DB: POST api/service-requests.php (category,type,...)
    DB-->>O: SR created (status=Submitted)
    E->>DB: POST approve (manage_services + approve_requests)
    DB-->>E: status=Approved, approved_at
    E->>DB: POST assign-contractor (contractor_id)
    DB->>DB: set contractor_id; create request conversation + participants (owner,contractor,handler)
    C->>DB: GET api/service-requests.php (scoped to C)
    C->>DB: POST start-work (isContractorUser + is assigned + Approved)
    DB-->>C: status=In Progress
    C->>DB: POST mark-done (invoice_file PDF, invoice_amount)
    DB-->>C: status=Done, invoice_status=Pending
    E->>DB: POST verify (verify_jobs)
    DB-->>E: status=Verified (closed)
    E->>DB: POST mark-paid (invoice_status Paid)
```

### 10.2 Chat participant-type translation

```mermaid
flowchart LR
    A[Session: portal=backend, user_type=contractor] --> B[requireChatAuth]
    B --> C{portal == backend?}
    C -- yes --> D[participantType = user_type = 'contractor']
    C -- no (owner) --> E[participantType = 'owner']
    D --> F[participant_type matches DB rows]
    E --> F
```

### 10.3 Unit lifecycle

```mermaid
flowchart TD
    U[Unit AVAILABLE / SALE_RENT] -->|create tenancy| T[Unit AVAILABLE / NOT_FOR_SALE_RENT<br>tenancy Active]
    T -->|expire/lease_end passed| A[Unit AVAILABLE / SALE_RENT]
    T -->|unit_status set to SOLD| S[tenancy Inactive +<br>marketing NOT_FOR_SALE_RENT]
```

---

## 11. Database Schema Summary (field-level)

| Table | PK | Notable FKs | Notable fields |
|-------|----|-------------|----------------|
| `role_permissions` | (role, permission) | — | role, permission |
| `employees` | employee_id | — | employee_name, employee_role, contact_no, email, password, avatar, status, created_at |
| `owners` | owner_id | — | salutation, owner_name, id_type, id_no, tin_no, contact_no, email, wechat, address, country, bank, account_no, emergency_contact, password, status, first_login, avatar, created_at, updated_at |
| `properties` | property_id | — | property_name, status |
| `blocks` | block_id | property_id | block_name, block_status |
| `property_units` | unit_id | property_id, block_id, owner_id | block, unit_no, unit_status, marketing_status, unit_category, UNIQUE(property_id,block,unit_no) |
| `tenants` | tenant_id | — | salutation, tenant_name, id_type, id_no, tin_no, contact_no, email, address1, address2, country, bank, account_no, emergency_contact, status |
| `prospects` | prospect_id | — | salutation, prospect_name, id_type, id_no, contact_no, email, address, country, interested_in, notes, status, pipeline_stage_id, created_at, updated_at |
| `tenancies` | tenancy_id | unit_id, tenant_id, handler_employee→employees | lease_start, lease_end, lease_duration, rental_amount, status |
| `maintenance_types` | type_id | — | category, type_name (UNIQUE), status |
| `service_requests` | request_id | requestor_id→owners, type_id→maintenance_types, handler_employee→employees, contractor_id→contractors | request_no (UNIQUE), category, driver/car fields, photo_attachment, condition_rating, remark, status, all `*_at` timestamps, invoice_file, invoice_amount, invoice_uploaded_at, invoice_status |
| `appointments` | appointment_id | unit_id, sales_person→employees, created_by→employees | appointment_no, prospect_name, prospect_contact, appointment_type, appointment_date, appointment_time, outcome, notes |
| `announcements` | announcement_id | published_by→employees | announcement_no, title, content, start_date, end_date, target_type, status |
| `announcement_targets` | (announcement_id, property_id) | announcement_id, property_id, block_id, unit_id | target_level |
| `contractors` | contractor_id | — | company_name, contact_person, contact_no, email, password, bank, account_no, cidb_no, insurance_details, insurance_expiry, performance_score, avatar, status |
| `contractor_trades` | (contractor_id, trade) | contractor_id→contractors, trade→maintenance_types.type_name | — |
| `defects` | defect_id | contractor_id→contractors, request_id→service_requests, reported_by/resolved_by→employees | defect_no, title, description, status, resolution_notes, timestamps |
| `conversations` | conversation_id | request_id→service_requests | type |
| `conversation_participants` | (conv,id,type) | conversation_id | participant_id, participant_type, last_read_at |
| `messages` | message_id | conversation_id | sender_id, sender_type, message, created_at |
| `customer_statements` | statement_id | property_id, owner_id, uploaded_by→employees | statement_no, title, statement_period, description, file_name, file_path, file_size, file_type, viewed_at |
| `pipeline_stages` | stage_id | — | stage_name, sort_order, pipeline_type |

**CRITICAL constraint:** `contractor_trades.trade` → `maintenance_types.type_name` (requires the UNIQUE index on `type_name`). When editing a contractor's trades, non-existent type names are silently skipped (insert guard).

---

## 12. Cross-Cutting Rules & Gotchas

1. **CSRF:** every POST/PUT/DELETE API requires `verify_csrf()` — token in body `csrf_token` or header `X-CSRF-Token`.
2. **Password migration:** SHA-256 hashes auto-upgrade to bcrypt on first successful login (`verifyPassword` fallback + `hashPassword` write).
3. **Soft deletes:** owners, tenants, employees, contractors are *deactivated* (`status='Inactive'`), not deleted. `properties` and `maintenance_types` are the only true deletes (with guards).
4. **Contractor data scoping is server-enforced:** GET `service-requests` and GET `defects` always scope to the logged-in contractor.
5. **Duplicate unit prevention:** `UNIQUE(property_id, block, unit_no)` + API duplicate checks.
6. **`first_login`:** new owners are forced to change password on first login.
7. **Session timeout:** 1800s; API calls are exempt (`IS_API` short-circuits).
8. **`IS_CONTRACTOR_PAGE` constant:** was dead code — fully removed in the portal consolidation. Do not reintroduce.
9. **Portal consolidation invariant:** chat participant types must remain the exact strings `'employee'` / `'owner'` / `'contractor'`; the backend portal translates `user_type` at `requireChatAuth()`.
10. **Auto-generated IDs** use `generateId()` (`EMP`, `CON`, `OW`, `TN`, `PROS`, `PROP`, `APPT`) — custom IDs allowed with regex `^[A-Za-z0-9]+$`.
11. **`upload_url()`** returns blank avatar when path empty.
12. **Statement uploads/downloads** enforce owner-scoping server-side; `statement-download.php` refuses cross-owner access.

---

## 13. Test Coverage Reference

- `test_backend.php` — 59 unit/auth tests (session functions, permission checks, header rendering, DB data presence, password validation).
- `test_quick.php` — 18 HTTP integration tests (legacy 301 redirects, page rendering, server-side + API login for employee/contractor).
- Manual verification flows: owner login → SR submit → inspector approve/assign → contractor start-work/mark-done → inspector verify/mark-paid → chat conversations auto-created on assignment.
