# FAPI — Faculty Appraisal & Performance Index API

## Backend Coding Agent

You are a backend coding agent for this PHP REST API. Follow every convention below precisely when creating or modifying code.

---

## Stack

- **Framework**: Slim Framework v3 (via `linways/slim ^1.0.0`)
- **Language**: PHP
- **Auth**: JWT (HS512) via `linways/linways-auth`
- **Validation**: `respect/validation ^1.1`
- **Migrations**: Phinx (`phinx.yml`)
- **Testing**: PHPUnit 7 with Faker
- **Dependencies**: `linways/linways-base ^1.0.17`, `linways/nucleus-core ~1.0.0`, `linways/ams-professional-core dev-master-v4`, `linways/workflow-core dev-master-v4`

---

## What This Project Is

FAPI is a configurable, multi-tier faculty appraisal system. It manages appraisal applications, reusable question banks, flexible review pipelines (self → HOD → Director → …), section-based evaluations, faculty assignments, file uploads to S3, external module API integrations, and aggregated reporting.

Domain document: [`claude-plans/fapi-architecture (1).md`](../../claude-plans/fapi-architecture%20(1).md)
Phase 2 plan: [`phase-2-plan.md`](phase-2-plan.md)

---

## FAPI-Specific Overrides

### Namespace root

```
com\linways\fapi\          ← root (replaces com\linways\wm\)
com\linways\fapi\api\v1\{module}\controller\{Module}Controller
com\linways\fapi\core\service\{Module}Service
com\linways\fapi\core\dto\{Module}
com\linways\fapi\core\mapper\{Module}ServiceMapper
com\linways\fapi\core\exception\{Module}Exception
com\linways\fapi\core\request\Search{Module}Request
```

### Database table prefix & naming rules

All tables use the `fapi_` prefix. Follow these conventions exactly:

| Convention | Rule | Example |
|---|---|---|
| Table names | lowercase `snake_case`, `fapi_` prefix | `fapi_faculty_enrollment` |
| Primary key | `id` (auto-increment) | `id INT NOT NULL AUTO_INCREMENT` |
| Foreign keys | `<referenced_table>_id` | `fapi_application_id`, `fapi_review_tier_id` |
| Booleans | `is_` or `has_` prefix, `TINYINT(1)` | `is_published`, `has_marks` |
| JSON columns | Descriptive name stored as `JSON` | `rule`, `properties`, `visible_to_tiers` |
| Status enums | `VARCHAR(20) NOT NULL` with valid values enforced in service layer | `status VARCHAR(20)` |
| Audit columns | Present on every table | `created_by`, `created_date`, `updated_by`, `updated_date` |

> `created_by` / `updated_by` reference `staffaccounts(staffID)` — the existing staff table. Do not create a new users table.

### Naming conventions

| Type | Convention | Example |
|---|---|---|
| Classes | PascalCase | `ApplicationService` |
| Methods | camelCase | `saveApplication()` |
| Properties | camelCase | `applicationId` |
| DB columns | snake_case | `fapi_application_id` |
| DB tables | `fapi_` prefix + snake_case | `fapi_review_tier` |
| Constants | UPPER_SNAKE_CASE | `CREATE_APPLICATION_FAILED` |
| Files | PascalCase matching class name | `ApplicationController.php` |

### Route prefix

All routes are registered under `/v1/` (set in `bootstrap/routes.php`). Module route groups go inside `src/com/linways/v1/routes.php`.

```php
// src/com/linways/v1/routes.php
$app->group('/application', function () use ($app) {
    require SOURCE_DIR . '/v1/application/routes.php';
});
```

---

## Directory Structure

```
fapi/api/
├── bootstrap/
│   ├── app.php            ← Slim app bootstrap (defines SOURCE_DIR)
│   ├── controllers.php    ← Register 9 DI container entries
│   ├── middlewares.php    ← JWT extraction → $GLOBALS, CORS
│   └── routes.php         ← Mounts /v1 group
├── db/
│   ├── migrations/        ← 16 Phinx migration files
│   ├── seeds/
│   └── manager.php
├── menu_toggles/          ← Menu registration migrations
│   ├── 20260427200001_fapi_admin_menus.php
│   ├── 20260427200002_fapi_faculty_menus.php
│   └── 20260427200003_fapi_evaluator_menus.php
├── src/com/linways/
│   ├── core/
│   │   ├── dto/
│   │   │   ├── Application.php
│   │   │   ├── AssignedApplication.php
│   │   │   ├── FacultyAssignment.php
│   │   │   ├── FacultyEnrollment.php
│   │   │   ├── ModuleApi.php
│   │   │   ├── Question.php
│   │   │   ├── QuestionResponse.php
│   │   │   ├── ReviewTier.php
│   │   │   ├── Section.php
│   │   │   └── TierSubmission.php
│   │   ├── exception/
│   │   │   └── FapiException.php   ← single exception class, all codes
│   │   ├── mapper/
│   │   │   ├── ApplicationServiceMapper.php
│   │   │   ├── EnrollmentServiceMapper.php
│   │   │   ├── EvaluatorServiceMapper.php
│   │   │   ├── FacultyAssignmentServiceMapper.php
│   │   │   ├── ModuleApiServiceMapper.php
│   │   │   ├── QuestionResponseServiceMapper.php
│   │   │   ├── QuestionServiceMapper.php
│   │   │   ├── ReviewTierServiceMapper.php
│   │   │   ├── SectionServiceMapper.php
│   │   │   └── TierSubmissionServiceMapper.php
│   │   ├── request/
│   │   │   ├── SearchApplicationRequest.php
│   │   │   ├── SearchEnrollmentRequest.php
│   │   │   ├── SearchFacultyAssignmentRequest.php
│   │   │   ├── SearchModuleApiRequest.php
│   │   │   ├── SearchQuestionRequest.php
│   │   │   ├── SearchReviewTierRequest.php
│   │   │   └── SearchSectionRequest.php
│   │   └── service/
│   │       ├── BaseService.php             ← DB connection (extends MySqlQuery)
│   │       ├── ApplicationService.php
│   │       ├── EnrollmentService.php
│   │       ├── EvaluatorRuleService.php
│   │       ├── EvaluatorService.php
│   │       ├── FacultyAssignmentService.php
│   │       ├── ModuleApiService.php
│   │       ├── QuestionService.php
│   │       ├── ReviewTierService.php
│   │       ├── SectionService.php
│   │       ├── TierSubmissionService.php
│   │       └── UploadService.php
│   └── v1/
│       ├── BaseController.php      ← permission checks via PermissionService
│       ├── routes.php              ← register module groups here
│       ├── application/
│       │   ├── controller/ApplicationController.php  ← 19 methods (app+tier+section)
│       │   └── routes.php
│       ├── common/
│       │   ├── controller/CommonController.php       ← departments, staff search
│       │   └── routes.php
│       ├── enrollment/
│       │   ├── controller/EnrollmentController.php
│       │   └── routes.php
│       ├── evaluator/
│       │   ├── controller/EvaluatorController.php    ← overview, submissions
│       │   └── routes.php
│       ├── evaluatorrule/
│       │   ├── controller/EvaluatorRuleController.php
│       │   └── routes.php
│       ├── facultyassignment/
│       │   ├── controller/FacultyAssignmentController.php
│       │   └── routes.php
│       ├── moduleapi/
│       │   ├── controller/ModuleApiController.php    ← 8 methods
│       │   └── routes.php
│       ├── question/
│       │   ├── controller/QuestionController.php
│       │   └── routes.php
│       └── submission/
│           ├── controller/SubmissionController.php   ← eval form, autosave, submit, reopen
│           └── routes.php
├── test/
│   └── db_conf/
├── composer.json
├── phinx.yml
└── phpunit.xml
```

> `SOURCE_DIR` constant resolves to `src/com/linways` (defined in `bootstrap/app.php`).

---

## Auth Context (`$GLOBALS`)

Available in every service and controller after JWT middleware runs:

| Variable | Description |
|---|---|
| `$GLOBALS['userId']` | Current user ID |
| `$GLOBALS['userType']` | `STAFF` \| `STUDENT` \| `ADMIN` |
| `$GLOBALS['role']` | Role string |
| `$GLOBALS['departmentId']` | Department context (nullable) |
| `$GLOBALS['batchId']` | Batch context (nullable) |
| `$GLOBALS['semesterId']` | Semester context (nullable) |
| `$GLOBALS['collegeCode']` | Institution code |

---

## Existing Shared Exception

`FapiException` already exists at `src/com/linways/core/exception/FapiException.php`. Use it as the base for all module exceptions or add module-specific constants to it.

```php
namespace com\linways\fapi\core\exception;

use com\linways\base\exception\CoreException;

class FapiException extends CoreException
{
    const EMPTY_PARAMETERS   = "EMPTY_PARAMETERS";
    const INVALID_PARAMETERS = "INVALID_PARAMETERS";
}
```

---

## Key Domain Entities

Use these names consistently across DTOs, services, tables, and routes:

| Entity (PHP class) | Table | Key columns / notes |
|---|---|---|
| `Application` | `fapi_application` | `name`, `start_date`, `end_date`, `eval_start_date`, `eval_end_date`, `is_published`, `status` (draft→active→closed→archived), `is_results_published`, `properties` (JSON) |
| `ReviewTier` | `fapi_review_tier` | `fapi_application_id`, `name`, `order`, `rule` (JSON — resolves evaluator), `show_results_from_tier` (JSON array of tier orders). UNIQUE on `(fapi_application_id, order)`. |
| `Section` | `fapi_section` | `fapi_application_id`, `name`, `description`, `display_order`. Scoped to one application. UNIQUE on `(fapi_application_id, display_order)`. |
| `Question` | `fapi_question` | `name`, `has_marks`, `max_marks`, `has_textarea`, `has_file_upload`, `module_api_code`, `has_richtext`, `richtext_default`. Independent — not tied to any application. |
| `SectionQuestion` | `fapi_section_question` | `fapi_section_id`, `fapi_question_id`, `order`. Links questions into sections. UNIQUE on `(fapi_section_id, fapi_question_id)`. |
| `SectionTier` | `fapi_section_tier` | `fapi_section_id`, `fapi_review_tier_id`. Controls which tiers evaluate which sections. |
| `FacultyAssignment` | `fapi_faculty_assignment` | `fapi_application_id`, `staffaccounts_id`. Admin assigns faculty to apps. UNIQUE on `(fapi_application_id, staffaccounts_id)`. |
| `FacultyEnrollment` | `fapi_faculty_enrollment` | `fapi_application_id`, `staffaccounts_id`, `status` (`enrolled`\|`completed`), `enrolled_at`. UNIQUE on `(fapi_application_id, staffaccounts_id)`. |
| `TierSubmission` | `fapi_tier_submission` | `fapi_faculty_enrollment_id`, `fapi_review_tier_id`, `evaluator_staffaccounts_id`, `status`, `submitted_at`. UNIQUE on `(fapi_faculty_enrollment_id, fapi_review_tier_id)`. |
| `QuestionResponse` | `fapi_question_response` | `fapi_tier_submission_id`, `fapi_question_id`, `fapi_section_id`, `marks` (DECIMAL 7,2), `remarks`, `lin_resource_id` (references lin_resource table). UNIQUE on `(fapi_tier_submission_id, fapi_question_id, fapi_section_id)`. |
| `AssignedApplication` | *(virtual — extends Application)* | Adds `dateWindowClosed`, `isEnrolled`, `enrollmentId`, `enrollmentStatus`, `enrollmentDate`. Returned by `getAvailableApplications()`. |
| `ModuleApi` | `fapi_module_api` | `name`, `code` (UNIQUE), `module`, `description`, `api_url`, `is_active`. External API definitions for dynamic question data. |
| *(no DTO)* | `fapi_evaluator_rule` | `label`, `code` (UNIQUE), `query` (parameterized SQL), `is_active`. Resolves evaluators dynamically. |
| *(no DTO)* | `fapi_tier_eligible_evaluator` | `fapi_tier_submission_id`, `staffaccounts_id`. Populated on enrollment from evaluator rules. |

**Delete behaviour:**
- Deleting an `Application` cascades through all its tiers, sections, enrollments → submissions → responses.
- Deleting a `Question` is **blocked** if it has any `SectionQuestion` or `QuestionResponse` rows — enforce this in the service layer before executing the delete.
- Deleting a `Section` cascades its `SectionQuestion`, `SectionTier`, and `QuestionResponse` rows.

---

## Submission Lifecycle States

`fapi_tier_submission.status` must use exactly these values:

```
locked → not_started → in_progress → submitted
```

| Transition | Trigger |
|---|---|
| *(created as `locked`)* | Enrollment creates all tier submissions; tier 1 starts as `not_started`, all others as `locked` |
| `locked` → `not_started` | Previous tier submitted; `unlockNextTier()` flips the next tier |
| `not_started` → `in_progress` | First auto-save by the evaluator; `claimIfNotStarted()` atomically sets evaluator |
| `in_progress` → `submitted` | Evaluator passes hard validation and confirms submit |

Business rules (enforced in `TierSubmissionService`):
- Tier 1 is `not_started` from creation when the application has `is_published = 1` and `CURRENT_DATE BETWEEN start_date AND end_date`.
- Tier N (N > 1) unlocks only after the tier N-1 `fapi_tier_submission` for the same enrollment has `status = 'submitted'`.
- Once `submitted`, no `fapi_question_response` rows for that submission may be modified (except via `reopen()`).
- `submitted_at` **must** be set when status transitions to `submitted`; it must be `NULL` for other states.
- `reopen()` flips `submitted` back to `not_started` (admin-only action via `FAPI_APPLICATIONS` permission).
- Evaluator claim uses atomic `UPDATE ... SET evaluator_staffaccounts_id = COALESCE(evaluator_staffaccounts_id, ?)` to prevent race conditions.

`fapi_faculty_enrollment.status` values:
- `enrolled` — faculty joined; pipeline not yet complete
- `completed` — all tiers submitted (set by aggregation engine after final tier locks)

---

## Database Migrations

Create migration files in `db/migrations/`. Use the Phinx class name `Create{Entity}Table`.

**Standard table template** (adapt columns per entity):

```php
<?php
use Phinx\Migration\AbstractMigration;

class CreateFapiApplicationTable extends AbstractMigration {
    public function change() {
        $this->execute("CREATE TABLE `fapi_application` (
            `id`           INT NOT NULL AUTO_INCREMENT,
            `name`         VARCHAR(300) NOT NULL,
            `description`  TEXT,
            `start_date`   DATE NOT NULL,
            `end_date`     DATE NOT NULL,
            `is_published` TINYINT(1) NOT NULL DEFAULT 0,
            `properties`   JSON COMMENT 'JSON',
            `created_by`   INT,
            `created_date` DATETIME DEFAULT CURRENT_TIMESTAMP,
            `updated_by`   INT,
            `updated_date` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
    }
}
```

**Column rules:**
- All tables include `created_by INT`, `created_date DATETIME`, `updated_by INT`, `updated_date DATETIME`.
- Boolean flags: `TINYINT(1) NOT NULL DEFAULT 0`.
- JSON fields: `JSON COMMENT 'JSON'` (e.g., `rule`, `properties`, `visible_to_tiers`).
- Marks: `DECIMAL(7,2)` — supports fractional scores (e.g., 8.5/10) without floating-point drift.
- Status columns: `VARCHAR(20) NOT NULL DEFAULT 'not_started'`.
- Foreign keys follow the pattern `fapi_<referenced_table>_id INT NOT NULL`.
- Staff references use `INT` matching `staffaccounts(staffID)`.

Run migrations:
```bash
./lcli db:migrate -all
```

---

## Creating a New Module — Step-by-Step

### Step 1: DTO (`src/com/linways/core/dto/{Module}.php`)

```php
<?php
namespace com\linways\fapi\core\dto;

use com\linways\base\dto\BaseDTO;

class {Module} extends BaseDTO {
    public $id;
    public $name;
    public $description;
    public $isActive = 1;
    // JSON fields stored as string in DB, decoded to object in mapper
    public $rule;
    public $createdDate;
    public $updatedDate;
}
```

### Step 2: Exception (`src/com/linways/core/exception/{Module}Exception.php`)

```php
<?php
namespace com\linways\fapi\core\exception;

use com\linways\base\exception\CoreException;

class {Module}Exception extends CoreException {
    const CREATE_{MODULE}_FAILED = [1, "{Module} creation failed"];
    const UPDATE_{MODULE}_FAILED = [2, "{Module} update failed"];
    const DELETE_{MODULE}_FAILED = [3, "{Module} deletion failed"];
    const {MODULE}_NOT_FOUND     = [4, "{Module} not found"];
    const INVALID_{MODULE}       = [5, "Invalid {module} data"];
}
```

### Step 3: Mapper (`src/com/linways/core/mapper/{Module}ServiceMapper.php`)

```php
<?php
namespace com\linways\fapi\core\mapper;

use com\linways\base\mapper\ResultMap;
use com\linways\base\mapper\Result;

class {Module}ServiceMapper {
    public static function get{Module}ListMapper() {
        return (new ResultMap())
            ->setClass(\com\linways\fapi\core\dto\{Module}::class)
            ->addResult(new Result("id",          "id",          "INT"))
            ->addResult(new Result("name",        "name"))
            ->addResult(new Result("description", "description"))
            ->addResult(new Result("isActive",    "is_active",   "INT"))
            ->addResult(new Result("rule",        "rule",        "OBJECT_FROM_JSON"))
            ->addResult(new Result("createdDate", "created_date"));
    }
}
```

**Mapper type constants:**
- `"INT"` — cast to integer
- `"OBJECT_FROM_JSON"` — JSON decode to stdClass
- `"OBJECT_ARRAY"` — JSON decode to array
- *(omit)* — string passthrough

### Step 4: Request Object (`src/com/linways/core/request/Search{Module}Request.php`)

```php
<?php
namespace com\linways\fapi\core\request;

use com\linways\base\request\BaseRequest;

class Search{Module}Request extends BaseRequest {
    public $id;
    public $name;
    public $isActive;
    public $limit  = 20;
    public $offset = 0;
}
```

### Step 5: Service (`src/com/linways/core/service/{Module}Service.php`)

```php
<?php
namespace com\linways\fapi\core\service;

use com\linways\base\util\MakeSingletonTrait;
use com\linways\fapi\core\dto\{Module};
use com\linways\fapi\core\exception\{Module}Exception;
use com\linways\fapi\core\mapper\{Module}ServiceMapper;
use com\linways\fapi\core\request\Search{Module}Request;
use Respect\Validation\Validator as v;

class {Module}Service extends BaseService {
    use MakeSingletonTrait;

    private function __construct() {}

    public function save{Module}({Module} ${module}): {Module} {
        v::attribute("name", v::stringType()->notEmpty())
          ->assert(${module});

        return empty(${module}->id)
            ? $this->create{Module}(${module})
            : $this->update{Module}(${module});
    }

    private function create{Module}({Module} ${module}): {Module} {
        ${module} = $this->realEscapeObject(${module});
        $sql = "INSERT INTO `fapi_{module}` (`name`, `description`, `is_active`, `created_by`)
                VALUES (?, ?, 1, ?)";
        try {
            ${module}->id = $this->executeQueryUsingPreparedStatement($sql, [
                ${module}->name,
                ${module}->description,
                $GLOBALS['userId'],
            ], true);
        } catch (\Exception $e) {
            throw new {Module}Exception({Module}Exception::CREATE_{MODULE}_FAILED);
        }
        return ${module};
    }

    private function update{Module}({Module} ${module}): {Module} {
        ${module} = $this->realEscapeObject(${module});
        $sql = "UPDATE `fapi_{module}`
                SET `name` = ?,
                    `description` = ?,
                    `updated_by` = ?
                WHERE `id` = ?";
        try {
            $this->executeQueryUsingPreparedStatement($sql, [
                ${module}->name,
                ${module}->description,
                $GLOBALS['userId'],
                ${module}->id,
            ]);
        } catch (\Exception $e) {
            throw new {Module}Exception({Module}Exception::UPDATE_{MODULE}_FAILED);
        }
        return ${module};
    }

    public function search{Module}s(Search{Module}Request $request): array {
        $request = $this->realEscapeObject($request);
        $conditions = ["1=1"];
        $params = [];

        if (!empty($request->id))      { $conditions[] = "t.id = ?";        $params[] = $request->id; }
        if (!empty($request->name))    { $conditions[] = "t.name LIKE ?";   $params[] = "%{$request->name}%"; }
        if (isset($request->isActive)) { $conditions[] = "t.is_active = ?"; $params[] = $request->isActive; }

        $params[] = (int) $request->offset;
        $params[] = (int) $request->limit;

        $sql = "SELECT t.id, t.name, t.description, t.is_active, t.created_date
                FROM `fapi_{module}` t
                WHERE " . implode(" AND ", $conditions) . "
                ORDER BY t.id DESC
                LIMIT ?, ?";

        return $this->executeQueryForListUsingPreparedStatement($sql, $params, {Module}ServiceMapper::get{Module}ListMapper());
    }

    public function delete{Module}(int $id): void {
        $id = $this->realEscapeString($id);
        $sql = "UPDATE `fapi_{module}` SET `is_active` = 0 WHERE `id` = ?";
        try {
            $this->executeQueryUsingPreparedStatement($sql, [$id]);
        } catch (\Exception $e) {
            throw new {Module}Exception({Module}Exception::DELETE_{MODULE}_FAILED);
        }
    }
}
```

**Key BaseService query methods (prepared statements):**
- `$this->executeQueryUsingPreparedStatement($sql, $params, $isReturnKey = false)` — INSERT (pass `true` to get last insert ID), UPDATE, DELETE
- `$this->executeQueryForListUsingPreparedStatement($sql, $params, $mapper)` — SELECT returning array of DTOs
- `$this->executeQueryForObjectUsingPreparedStatement($sql, $params, $isReturnKey, $mapper)` — SELECT returning single DTO
- Use `?` positional placeholders in SQL — values are bound automatically, no manual escaping needed

### Step 6: Controller (`src/com/linways/v1/{module}/controller/{Module}Controller.php`)

```php
<?php
namespace com\linways\fapi\api\v1\{module}\controller;

use com\linways\fapi\core\dto\{Module};
use com\linways\fapi\core\request\Search{Module}Request;
use com\linways\fapi\core\service\{Module}Service;
use Linways\Slim\Utils\ResponseUtils;
use Slim\Http\Request;
use Slim\Http\Response;

class {Module}Controller extends BaseController {

    public $permissions_save{Module}    = ['{MODULE}_WRITE'];
    public $permissions_search{Module}s = ['{MODULE}_READ'];
    public $permissions_delete{Module}  = ['{MODULE}_DELETE'];

    protected function save{Module}(Request $request, Response $response) {
        try {
            $body     = $request->getParsedBody();
            $id       = $request->getAttribute('id');
            ${module} = new {Module}();
            ${module}->id          = $id ?? null;
            ${module}->name        = $body['name']        ?? null;
            ${module}->description = $body['description'] ?? null;

            $result = {Module}Service::getInstance()->save{Module}(${module});
            return ResponseUtils::result($response, $result);
        } catch (\Exception $e) {
            return ResponseUtils::fault($response, $e);
        }
    }

    protected function search{Module}s(Request $request, Response $response) {
        try {
            $params  = $request->getQueryParams();
            $req     = new Search{Module}Request();
            $req->id       = $params['id']       ?? null;
            $req->name     = $params['name']      ?? null;
            $req->isActive = $params['isActive']  ?? 1;
            $req->limit    = $params['limit']     ?? 20;
            $req->offset   = $params['offset']    ?? 0;

            $result = {Module}Service::getInstance()->search{Module}s($req);
            return ResponseUtils::result($response, $result);
        } catch (\Exception $e) {
            return ResponseUtils::fault($response, $e);
        }
    }

    protected function delete{Module}(Request $request, Response $response) {
        try {
            $id = (int) $request->getAttribute('id');
            {Module}Service::getInstance()->delete{Module}($id);
            return ResponseUtils::result($response, null);
        } catch (\Exception $e) {
            return ResponseUtils::fault($response, $e);
        }
    }
}
```

### Step 7: Routes (`src/com/linways/v1/{module}/routes.php`)

```php
<?php
use Slim\App;

return function(App $app) {
    $app->get('[/]',          '{Module}Controller:search{Module}s');
    $app->post('[/]',         '{Module}Controller:save{Module}');
    $app->put('/{id}[/]',    '{Module}Controller:save{Module}');
    $app->delete('/{id}[/]', '{Module}Controller:delete{Module}');
};
```

### Step 8: Register Routes (`src/com/linways/v1/routes.php`)

```php
$app->group('/{module}', require SOURCE_DIR . '/v1/{module}/routes.php');
```

### Step 9: Register Controller (`bootstrap/controllers.php`)

```php
$container['{Module}Controller'] = function ($container) {
    return new \com\linways\fapi\api\v1\{module}\controller\{Module}Controller($container);
};
```

---

## Testing

### Service Test (`test/unit/service/{Module}ServiceTest.php`)

```php
<?php
namespace test\unit\service;

use test\unit\FapiTestCase;
use com\linways\fapi\core\dto\{Module};
use com\linways\fapi\core\service\{Module}Service;
use com\linways\fapi\core\request\Search{Module}Request;

class {Module}ServiceTest extends FapiTestCase {

    public function testCreate{Module}() {
        ${module}       = new {Module}();
        ${module}->name = $this->faker->word;

        $result = {Module}Service::getInstance()->save{Module}(${module});

        $this->assertNotEmpty($result->id);
        return $result->id;
    }

    /** @depends testCreate{Module} */
    public function testSearch{Module}s($id) {
        $req     = new Search{Module}Request();
        $req->id = $id;
        $results = {Module}Service::getInstance()->search{Module}s($req);
        $this->assertNotEmpty($results);
        $this->assertEquals($id, $results[0]->id);
    }
}
```

Run tests:
```bash
./vendor/bin/phpunit test/unit/service/{Module}ServiceTest.php
```

---

## Key Patterns & Rules

### Use prepared statements — never string interpolation
```php
// CORRECT — values bound via params, no escaping needed
$sql = "INSERT INTO `fapi_application` (`name`, `created_by`) VALUES (?, ?)";
$this->executeQueryUsingPreparedStatement($sql, [$dto->name, $GLOBALS['userId']], true);

// WRONG — never interpolate user input directly into SQL
$sql = "INSERT INTO `fapi_application` (`name`) VALUES ('{$dto->name}')";
```
Do NOT call `realEscapeObject()` before building queries — prepared statements handle binding automatically.

### Singleton services
```php
// Always use ::getInstance() — never `new ServiceClass()`
ApplicationService::getInstance()->saveApplication($dto);
```

### Response format
```php
// Success
return ResponseUtils::result($response, $data);

// Error
return ResponseUtils::fault($response, $exception);
```

### Validation (Respect\Validation)
```php
use Respect\Validation\Validator as v;

v::attribute("name", v::stringType()->notEmpty())
  ->attribute("status", v::in(["enrolled", "completed"])->notEmpty())
  ->assert($dto);
```

### Permission declarations on controllers
```php
// Property name must match exactly: permissions_{methodName}
public $permissions_saveApplication = ['FAPI_APPLICATION_WRITE'];
```
- Set `public $isPermissionsRequired = false;` on the controller to skip permission checks (use sparingly).

### JSON fields in DB
- Store as `JSON COMMENT 'JSON'` in MySQL.
- Use `"OBJECT_FROM_JSON"` in mapper to auto-decode on read.
- When saving: `json_encode($dto->rule)` before inserting.

### PHPDoc / Docblocks

Every `public` and `private` service method **must** have a docblock. Use the style established in `WorkflowService`:

**Standard public method docblock**
```php
/**
 * Brief one-sentence description of what the method does.
 *
 * Longer explanation if the behaviour is non-obvious (optional).
 *
 * @param  {Module}  ${module}  The DTO to persist.
 *
 * @return {Module}  The saved DTO with `id` populated.
 *
 * @throws {Module}Exception  If validation fails or the DB write fails.
 */
public function save{Module}({Module} ${module}): {Module}
```

**Private helper method docblock**
```php
/**
 * Inserts a new `fapi_{module}` row and returns the populated DTO.
 *
 * @param  {Module}  ${module}
 *
 * @return {Module}
 *
 * @throws {Module}Exception  CREATE_{MODULE}_FAILED
 */
private function create{Module}({Module} ${module}): {Module}
```

**Search / query method docblock**
```php
/**
 * Returns a list of {module}s that match the given criteria.
 *
 * @param  Search{Module}Request  $request  Filtering and pagination parameters.
 *
 * @return {Module}[]  Empty array when no rows match.
 *
 * @throws {Module}Exception  If the DB query fails.
 */
public function search{Module}s(Search{Module}Request $request): array
```

**Delete / status-toggle method docblock**
```php
/**
 * Soft-deletes the {module} by setting `is_active = 0`.
 *
 * @param  int  $id  Primary key of the record to delete.
 *
 * @return void
 *
 * @throws {Module}Exception  DELETE_{MODULE}_FAILED
 */
public function delete{Module}(int $id): void
```

**Rules:**
- `@param` — one per parameter: `@param  Type  $name  Description.`
- `@return` — always present; use `void` when the method has no return value.
- `@throws` — list every exception class that can propagate to the caller; include the constant name or a short condition after the class name.
- Use two spaces between the tag, type, variable name, and description to align columns (matches the project style in `WorkflowService`).
- Test methods (`test*`) and DTO/mapper classes do **not** need docblocks.

---

## What NOT to Do

- Do NOT use the `wm_` table prefix — use `fapi_` only.
- Do NOT use the `com\linways\wm` namespace — use `com\linways\fapi`.
- Do NOT interpolate user input into SQL strings — always use `?` placeholders with `executeQueryUsingPreparedStatement` / `executeQueryForListUsingPreparedStatement` / `executeQueryForObjectUsingPreparedStatement`.
- Always call `realEscapeObject()`
- Do NOT share `Section` records across applications — sections are application-scoped; only `fapi_question` definitions are shared.
- Do NOT allow edits to `fapi_question_response` rows once the parent `fapi_tier_submission.status` is `submitted`.
- Do NOT skip sequential gating — query `fapi_tier_submission` to verify the prior tier's status is `submitted` before allowing tier N access.
- Do NOT delete a `fapi_question` if it has linked `fapi_section_question` or `fapi_question_response` rows — enforce this restriction in the service layer before executing the delete.
- Do NOT use `FLOAT` or `DOUBLE` for marks columns — use `DECIMAL(7,2)`.
- Do NOT implement faculty enrollment filtering (department, teaching type, etc.) in this phase — the `fapi_application.properties` JSON column is reserved for that future feature.

---

## Complete API Route Map

All routes prefixed with `/api/v1/`. Permission codes listed in brackets.

### Common (`/common/`)
```
GET  /common/departments/         → CommonController::getDepartments       [no permissions required]
GET  /common/staff/               → CommonController::searchStaff          [no permissions required]
```

### Question (`/question/`)
```
GET    /question/                 → QuestionController::searchQuestions     [FAPI_QUESTION_BANK]
POST   /question/                 → QuestionController::saveQuestion       [FAPI_QUESTION_BANK]
PUT    /question/{id}/            → QuestionController::saveQuestion       [FAPI_QUESTION_BANK]
DELETE /question/{id}/            → QuestionController::deleteQuestion     [FAPI_QUESTION_BANK]
```

### Application (`/application/`) — includes nested tier & section routes
```
GET    /application/                            → searchApplications       [FAPI_APPLICATIONS]
GET    /application/{id}/                       → getApplication           [FAPI_APPLICATIONS]
POST   /application/                            → saveApplication          [FAPI_APPLICATIONS]
PUT    /application/{id}/                       → saveApplication          [FAPI_APPLICATIONS]
DELETE /application/{id}/                       → deleteApplication        [FAPI_APPLICATIONS]
PUT    /application/{id}/publish/               → togglePublish            [FAPI_APPLICATIONS]
PUT    /application/{id}/status/                → changeStatus             [FAPI_APPLICATIONS]
PUT    /application/{id}/results-publish/       → toggleResultsPublished   [FAPI_APPLICATIONS]

GET    /application/{appId}/tier/               → searchReviewTiers        [FAPI_APPLICATIONS]
POST   /application/{appId}/tier/               → saveReviewTier           [FAPI_APPLICATIONS]
PUT    /application/{appId}/tier/{id}/          → saveReviewTier           [FAPI_APPLICATIONS]
DELETE /application/{appId}/tier/{id}/          → deleteReviewTier         [FAPI_APPLICATIONS]

GET    /application/{appId}/section/                       → searchSections          [FAPI_APPLICATIONS]
POST   /application/{appId}/section/                       → saveSection             [FAPI_APPLICATIONS]
PUT    /application/{appId}/section/{id}/                  → saveSection             [FAPI_APPLICATIONS]
DELETE /application/{appId}/section/{id}/                  → deleteSection           [FAPI_APPLICATIONS]
POST   /application/{appId}/section/{id}/question/         → addQuestion             [FAPI_APPLICATIONS]
DELETE /application/{appId}/section/{id}/question/{qId}/   → removeQuestion          [FAPI_APPLICATIONS]
PUT    /application/{appId}/section/{id}/tiers/            → updateTiers             [FAPI_APPLICATIONS]
```

### Enrollment (`/enrollment/`)
```
GET  /enrollment/available/        → getAvailableApplications     [FAPI_FACULTY]
POST /enrollment/                  → enroll                       [FAPI_FACULTY]
GET  /enrollment/                  → searchEnrollments            [FAPI_FACULTY | FAPI_APPLICATIONS]
GET  /enrollment/{id}/             → getEnrollmentWithProgress    [FAPI_FACULTY | FAPI_APPLICATIONS]
```

### Submission (`/submission/`)
```
GET  /submission/{enrollmentId}/{tierId}/          → getEvaluationForm    [FAPI_FACULTY | FAPI_EVALUATOR_REVIEW]
POST /submission/{enrollmentId}/{tierId}/autosave/ → autoSave             [FAPI_FACULTY | FAPI_EVALUATOR_REVIEW]
POST /submission/{enrollmentId}/{tierId}/submit/   → submit               [FAPI_FACULTY | FAPI_EVALUATOR_REVIEW]
PUT  /submission/{enrollmentId}/{tierId}/reopen/   → reopen               [FAPI_APPLICATIONS]
```

### Evaluator (`/evaluator/`)
```
GET /evaluator/overview/                          → getMyOverview         [FAPI_EVALUATOR_REVIEW]
GET /evaluator/applications/{appId}/submissions/  → getMySubmissions      [FAPI_EVALUATOR_REVIEW]
```

### Evaluator Rule (`/evaluator-rule/`)
```
GET /evaluator-rule/              → getEvaluatorRules             [FAPI_APPLICATIONS]
```

### Faculty Assignment (`/faculty-assignment/`)
```
GET    /faculty-assignment/        → searchAssignments            [FAPI_APPLICATIONS]
POST   /faculty-assignment/        → assignFaculty                [FAPI_APPLICATIONS]
POST   /faculty-assignment/bulk/   → bulkAssign                   [FAPI_APPLICATIONS]
DELETE /faculty-assignment/{id}/   → removeAssignment             [FAPI_APPLICATIONS]
```

### Module API (`/module-api/`)
```
GET    /module-api/modules/             → getModules               [FAPI_MODULE_APIS]
GET    /module-api/                     → searchModuleApis         [FAPI_MODULE_APIS]
GET    /module-api/{code}/report-data/  → getModuleApiReportData   [FAPI_FACULTY]
GET    /module-api/{id}/                → getModuleApi             [FAPI_MODULE_APIS]
POST   /module-api/                     → saveModuleApi            [FAPI_MODULE_APIS]
PUT    /module-api/{id}/                → saveModuleApi            [FAPI_MODULE_APIS]
PUT    /module-api/{id}/toggle/         → toggleModuleApi          [FAPI_MODULE_APIS]
DELETE /module-api/{id}/                → deleteModuleApi          [FAPI_MODULE_APIS]
```

---

## Services Summary

### BaseService
Extends `MySqlQuery`. Manages PDO/MySQLi connections via `DB_PROFESIONAL_CONFIG` env var. All services extend this.

### QuestionService
CRUD for reusable question templates. Validates field type flags (at least one of hasMarks/hasTextarea/hasFileUpload/hasRichtext must be enabled). Checks `checkQuestionInUse()` before delete.

### ApplicationService
Application lifecycle management: `draft → active → closed → archived`. Includes `togglePublish()`, `changeStatus()`, `toggleResultsPublished()`. Validates date ranges (startDate < endDate, evalStartDate < evalEndDate).

### SectionService
Manages sections within applications. Handles display order resequencing, question linking (`addQuestion`/`removeQuestion`), and tier association updates (`updateSectionTiers`).

### ReviewTierService
Manages evaluation tiers per application. Enforces unique `(application_id, order)`. Stores evaluator resolution rules and tier visibility config as JSON.

### EvaluatorRuleService
Dynamic evaluator resolution via parameterized SQL. `resolveStaffsByCode()` substitutes `[[placeholder]]` tokens with bound parameters. `validateRuleQuery()` guards against non-SELECT/destructive SQL.

### EnrollmentService
Faculty self-enrollment. `enroll()` is **atomic**: creates enrollment + all tier submissions (tier 1 as `not_started`, others as `locked`) + populates eligible evaluators. `getAvailableApplications()` returns published, in-window apps assigned via `fapi_faculty_assignment`.

### TierSubmissionService
Core evaluation engine. `getEvaluationForm()` claims the submission atomically, loads sections/questions, and includes prior tier responses. `autoSave()` uses `INSERT ... ON DUPLICATE KEY UPDATE`. `submit()` validates all required fields then locks. `reopen()` lets admins flip submitted back to not_started.

### EvaluatorService
Dashboard aggregations. `getOverview()` returns pending/completed counts per app. `getSubmissionsForApp()` returns grouped submissions.

### FacultyAssignmentService
Admin assigns faculty to applications. `bulkAssignFaculty()` uses `INSERT IGNORE` for idempotency. `removeAssignment()` deletes the assignment.

### ModuleApiService
Manages external API definitions (code, URL, module). `fetchReportData()` calls the external API with enrollment context and forwards the JWT token.

### UploadService
S3 file upload integration. `getUploadContext()` returns S3 config. `registerFiles()` registers uploaded S3 files in `lin_resource` via `ResourceService`. `getFileInfo()` generates presigned download URLs. `deleteFile()` removes from S3 and lin_resource.

---

## Exception Codes (FapiException)

All codes are constants on `FapiException`:

**Generic**: `EMPTY_PARAMETERS`, `INVALID_PARAMETERS`
**Question**: `CREATE_QUESTION_FAILED`, `UPDATE_QUESTION_FAILED`, `DELETE_QUESTION_FAILED`, `QUESTION_NOT_FOUND`, `QUESTION_IN_USE`
**Application**: `CREATE_APPLICATION_FAILED`, `UPDATE_APPLICATION_FAILED`, `DELETE_APPLICATION_FAILED`, `APPLICATION_NOT_FOUND`, `INVALID_DATE_RANGE`, `INVALID_APP_STATUS`
**ReviewTier**: `CREATE_REVIEW_TIER_FAILED`, `UPDATE_REVIEW_TIER_FAILED`, `DELETE_REVIEW_TIER_FAILED`, `REVIEW_TIER_NOT_FOUND`, `REVIEW_TIER_IN_USE`, `DUPLICATE_TIER_ORDER`
**Section**: `CREATE_SECTION_FAILED`, `UPDATE_SECTION_FAILED`, `DELETE_SECTION_FAILED`, `SECTION_NOT_FOUND`, `DUPLICATE_DISPLAY_ORDER`, `QUESTION_ALREADY_IN_SECTION`
**EvaluatorRule**: `CREATE_EVALUATOR_RULE_FAILED`, `UPDATE_EVALUATOR_RULE_FAILED`, `EVALUATOR_RULE_NOT_FOUND`, `DUPLICATE_RULE_CODE`
**Enrollment**: `ALREADY_ENROLLED`, `APP_NOT_AVAILABLE`, `ENROLLMENT_FAILED`, `ENROLLMENT_NOT_FOUND`
**TierSubmission**: `TIER_LOCKED`, `TIER_NOT_ELIGIBLE`, `TIER_CLAIMED`, `VALIDATION_FAILED`, `SUBMISSION_FAILED`, `ALREADY_SUBMITTED`, `APP_WINDOW_CLOSED`, `TIER_ACTION_WINDOW_CLOSED`
**ModuleApi**: `CREATE_MODULE_API_FAILED`, `UPDATE_MODULE_API_FAILED`, `DELETE_MODULE_API_FAILED`, `MODULE_API_NOT_FOUND`, `DUPLICATE_MODULE_API_CODE`
**FacultyAssignment**: `CREATE_FACULTY_ASSIGNMENT_FAILED`, `DELETE_FACULTY_ASSIGNMENT_FAILED`, `FACULTY_ALREADY_ASSIGNED`
**Upload**: `UPLOAD_FAILED`, `RESOURCE_NOT_FOUND`, `DELETE_RESOURCE_FAILED`

---

## Database Migrations

| # | File | What it does |
|---|------|-------------|
| 1 | `20260422100001_create_fapi_question_table.php` | `fapi_question` — reusable question templates |
| 2 | `20260422100002_create_fapi_application_table.php` | `fapi_application` — appraisal campaigns |
| 3 | `20260422100003_create_fapi_review_tier_table.php` | `fapi_review_tier` — evaluation tier definitions |
| 4 | `20260422100004_create_fapi_section_table.php` | `fapi_section` — question groupings |
| 5 | `20260422100005_create_fapi_section_question_table.php` | `fapi_section_question` — section↔question junction |
| 6 | `20260422100006_create_fapi_section_tier_table.php` | `fapi_section_tier` — section↔tier junction |
| 7 | `20260422100007_create_fapi_faculty_enrollment_table.php` | `fapi_faculty_enrollment` — faculty participation |
| 8 | `20260422100008_create_fapi_tier_submission_table.php` | `fapi_tier_submission` — per-tier evaluation states |
| 9 | `20260422100009_create_fapi_evaluator_rule_table.php` | `fapi_evaluator_rule` — parameterized SQL rules |
| 10 | `20260422100010_create_fapi_tier_eligible_evaluator_table.php` | `fapi_tier_eligible_evaluator` — eligible evaluators per submission |
| 11 | `20260422100011_create_fapi_question_response_table.php` | `fapi_question_response` — evaluator responses |
| 12 | `20260424100012_create_fapi_module_api_table.php` | `fapi_module_api` — external API definitions |
| 13 | `20260424100013_create_fapi_faculty_assignment_table.php` | `fapi_faculty_assignment` — admin faculty assignments |
| 14 | `20260424100014_alter_fapi_application_add_eval_fields.php` | ADD `eval_start_date`, `eval_end_date`, `status` to `fapi_application` |
| 15 | `20260424100015_alter_fapi_question_add_module_fields.php` | ADD `module_api_code`, `has_richtext`, `richtext_default` to `fapi_question` |
| 16 | `20260514100016_RenameFilePathToLinResourceIdOnQuestionResponse.php` | RENAME `file_path` → `lin_resource_id` on `fapi_question_response` |

---

## Permission Model

Permission codes checked at controller level via `public $permissions_{methodName}`:

| Code | Used By | Grants Access To |
|------|---------|------------------|
| `FAPI_QUESTION_BANK` | QuestionController | Question CRUD |
| `FAPI_APPLICATIONS` | ApplicationController, EnrollmentController, SubmissionController (reopen), EvaluatorRuleController, FacultyAssignmentController | Application admin, tiers, sections, enrollments, assignments |
| `FAPI_MODULE_APIS` | ModuleApiController | Module API management |
| `FAPI_FACULTY` | EnrollmentController, SubmissionController, ModuleApiController (report data) | Faculty self-enrollment, evaluation form, module data |
| `FAPI_EVALUATOR_REVIEW` | SubmissionController, EvaluatorController | Evaluator dashboard, form access, submission |

OR logic: user needs **at least one** listed permission to access the method.

---

## Implementation Status

### Complete (Phase 1)
1. Question Bank — CRUD with field type flags, module API integration, richtext support
2. Application Management — Full lifecycle (draft→active→closed→archived), publish/results toggles
3. Section Management — Display ordering, question linking, tier associations
4. Review Tier Setup — JSON rules, tier visibility, unique ordering
5. Faculty Assignment — Admin single + bulk assignment to applications
6. Faculty Self-Enrollment — Atomic enrollment with tier submission + evaluator creation
7. Tier Submission Workflow — locked→not_started→in_progress→submitted, atomic claim, autosave, hard submit, admin reopen
8. Evaluator Rule Engine — Dynamic SQL-based evaluator resolution with placeholder substitution
9. Evaluator Dashboard — Overview aggregation, per-app submission listing
10. Module API Integration — External API definitions, report data fetching with JWT forwarding
11. File Upload — S3 upload via ResourceService, lin_resource registration, presigned URLs
12. Common — Department listing, staff search

### Phase 2 (Deferred) — see `phase-2-plan.md`
1. Export — Excel/PDF export for summary & faculty reports
2. Application Eligibility Criteria — `properties` JSON for department/role filtering
3. Reporting Dashboard — Summary and detailed faculty reports (endpoints defined, service not yet implemented)

## graphify

This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
