Visit onboard is the Visit wellness onboarding flow used for eligible members under a selected client policy. The browser first checks how many members are still pending, then starts a background job that pages through employee_polices rows, groups each family by emp_code, sends one payload per family to the external Visit API, and stores the returned referenceId back into employee_polices.wellness_onboard.

i
Core files The status check, queue trigger, payload builder, API call, and DB update logic all live in app/Controllers/EmployeeController.php. The queue worker entry is registered in app/Controllers/JobWorker.php.

Overview

flowchart TD A[User selects policy] --> B[checkWellnessOnboardStatus] B --> C{Pending members > 0} C -->|No| D[Hide onboard action] C -->|Yes| E[Show Visit onboard action] E --> F[initiateWellnessOnboard] F --> G[Jobs::addJob] G --> H[JobWorker executes initiateWellnessOnboardJob] H --> I[Fetch one page of employee policy rows] I --> J[Group rows by emp_code] J --> K[Build family payload] K --> L[POST to Visit API] L --> M[Store referenceId in wellness_onboard] M --> N{Page was full} N -->|Yes| O[Queue next page job] N -->|No| P[Batch complete]

Prerequisites

!
A valid Visit plan ID must be mapped to the client policy in the policy edit page before using Visit onboard. Without a proper wellness plan mapping, the current eligibility query will not pick that policy for onboarding and the flow will not start as expected.

Before testing or using this feature, confirm that the selected client policy has a valid wellness_plan_id configured in the policy edit screen. This mapping is one of the core prerequisites checked by the current query.

Entry points

The current implementation is split across two browser-facing routes and one queued job handler:

Entry pointCurrent responsibility
GET checkWellnessOnboardStatus/{client_policy_id} Counts eligible member rows and returns the number in response.data.
GET initiateWellnessOnboard/{client_policy_id} Queues the background job and immediately returns Process started.
initiateWellnessOnboardJob($arr) Processes one page, sends family payloads to the Visit API, persists results, and queues the next page if needed.
$routes->get("checkWellnessOnboardStatus/(:any)", "EmployeeController::checkWellnessOnboardStatus/$1");
$routes->get("initiateWellnessOnboard/(:any)", "EmployeeController::initiateWellnessOnboard/$1");

'initiateWellnessOnboardJob' => [
    'type' => 'CC',
    'handler' => 'App\Controllers\EmployeeController',
],

On the admin page, app/Views/employee_upload.php calls the count endpoint when a policy is selected, shows the CTA only when the count is greater than zero, and asks for user confirmation before calling the initiate endpoint.

!
Current trigger style The existing implementation starts work from a browser GET route and then hands off the heavy work to the queue. If you change the route method or path, update Routes.php, the frontend AJAX calls, and any ACL expectations together.

Eligibility rules

Both checkWellnessOnboardStatus() and initiateWellnessOnboardJob() use nearly the same base query. A member is considered eligible only when all of these conditions are true:

ConditionMeaning in current code
employee_polices.client_policy_id = {selected id} The job is always scoped to one chosen client policy.
employee_polices.is_active = 1 Only active employee policy rows are considered.
employee_polices.status = 'active' Inactive policy-members are excluded.
employee_polices.wellness_onboard = '0' Already onboarded rows are skipped because this column later stores the Visit referenceId.
employees.emp_status = 'active' and employees.is_active = 1 Only active employees/dependants are sent.
cp.wellness_plan_id is present The selected policy must have a wellness plan configured.
cp.wellness_vendor_id is null, empty, or 0 This is how the current code filters policies for this flow today.
cp.policy_status = 1 and cp.is_active = 1 The client policy itself must be active.

The status endpoint returns a count of matching member rows, not a count of grouped families. That is why the button text says employees, while the job later sends one API payload per family group.

Async flow

  1. Check the pending count

    checkWellnessOnboardStatus($client_policy_id) runs the eligibility query and returns count($data).

  2. Queue the first job

    initiateWellnessOnboard($client_policy_id) inserts a job with the name initiateWellnessOnboardJob and payload ['client_policy_id' => ...].

  3. Worker resolves the handler

    JobWorker::$event_class_mapping maps that job name back to EmployeeController.

  4. Process one page of rows

    The job defaults to page = 1, per_page = 50, and calculates the SQL offset from those values.

  5. Group the current page by family

    Rows are grouped by emp_code before building API payloads.

  6. Send to Visit and persist the response

    The job posts each family payload, then stores the returned referenceId into all member rows for that family.

  7. Queue the next page only when needed

    If the current query returns exactly per_page rows, the job assumes more data may exist and queues page + 1.

Jobs::addJob([
    'job_name' => 'initiateWellnessOnboardJob',
    'payload'  => [
        'client_policy_id' => $client_policy_id,
        'page'             => $page + 1,
        'per_page'         => $perPage,
    ]
]);

The legacy method initiateWellnessOnboardJobOLD() still exists in the controller, but the active queue mapping points to the current initiateWellnessOnboardJob() implementation.

Family payload

The job builds one outbound payload per emp_code. The first row in the family is used as the policy-level reference, and every family member becomes one entry in memberDetails.

Outbound fieldSource in current code
policyDetails.policyNumbercp.policy_no
policyDetails.employeeIdemp_code
policyDetails.policyNameHardcoded GMC
policyDetails.policyStartDatecp.policy_start_date
policyDetails.policyEndDatecp.policy_end_date
policyDetails.plancp.wellness_plan_id
policyDetails.sourceHardcoded NHANCE
policyDetails.employerclients.short_name
memberDetails[].memberIdemployee_polices.id
memberDetails[].relationshipNameMapped by mapRelationship()
memberDetails[].genderM => Male, otherwise Female
[
    'policyDetails' => [
        'policyNumber'    => $primary['policy_no'],
        'employeeId'      => $empCode,
        'policyName'      => 'GMC',
        'policyStartDate' => $primary['cp_policy_start_date'],
        'policyEndDate'   => $primary['policy_end_date'],
        'plan'            => $primary['wellness_plan_id'],
        'source'          => 'NHANCE',
        'employer'        => $primary['short_name'],
        'employeeCode'    => $empCode,
    ],
    'memberDetails' => [
        [
            'memberId'         => $row['id'],
            'name'             => $row['name'],
            'phone'            => $row['mobile'],
            'email'            => $row['email_corporate'],
            'relationshipName' => $this->mapRelationship($row['relationship'], $row['gender']),
            'gender'           => $row['gender'] == 'M' ? 'Male' : 'Female',
            'dob'              => $row['dob'],
        ],
    ],
]
!
Important mapping rule mapRelationship() throws an exception for spouse when gender is missing. If spouse data is incomplete, the job can fail before the API call is made.

API integration

sendFamiliesToWellnessApi() uses the CI4 cURL service and reads its runtime configuration from environment values:

Env keyUsage
WELLNESS_ONBOARD_ENDPOINT_URL Target URL for the Visit onboarding POST request.
WELLNESS_ONBOARD_AUTHORIZATION Basic auth token value appended to the Authorization header.

Each family is posted as JSON with http_errors = false and a 30 second timeout. The helper stores the raw API result back onto the in-memory family array under apiResponse so the next step can decide whether to persist anything.

$response = $client->post($endpointUrl, [
    'headers' => [
        'Content-Type'  => 'application/json',
        'Authorization' => 'Basic ' . getenv('WELLNESS_ONBOARD_AUTHORIZATION'),
    ],
    'body'        => json_encode($family),
    'http_errors' => false,
    'timeout'     => 30,
]);

If the HTTP client throws, the exception is captured into apiResponse['error'] with a synthetic statusCode of 0.

Response examples

i
Sample data only The JSON below uses placeholder IDs, names, phones, and emails so no live user data appears in documentation. Production responses follow the same shape with real values.

Visit success response (HTTP 2xx with a body the job can decode). updateWellnessOnboardResponseToDB() reads referenceId from the decoded JSON and writes it to employee_polices.wellness_onboard for each policyDetails[].memberId (employee policy row id).

{
  "message": "success",
  "body": "The policy details are posted successfully",
  "policyDetails": [
    {
      "memberId": "100001",
      "name": "Primary Member Example",
      "phone": "9000000001",
      "email": "primary.member@example.com",
      "relationshipName": "husband",
      "gender": "Male",
      "dob": "1962-11-25"
    },
    {
      "memberId": "100002",
      "name": "Dependent Member Example",
      "phone": "9000000001",
      "email": "dependent.member@example.com",
      "relationshipName": "son",
      "gender": "Male",
      "dob": "1996-02-13"
    }
  ],
  "referenceId": "00000000000000000000-NHANCE-1700000000123"
}

Visit failure responses (same endpoint; when validation or business rules fail, the body typically looks like one of these). In these cases the current persistence step skips updating rows because there is no referenceId.

{
  "message": "failed",
  "errorMessage": "Invalid name"
}
{
  "message": "failed",
  "errorMessage": "Invalid mobileno"
}
{
  "message": "failed",
  "errorMessage": "invalid [\"null\",\"string\"]: 100010"
}

The last shape is a schema-style rejection: the bracketed part describes the expected type(s), and the trailing id is the member identifier the API could not accept (shown here as a placeholder id, not production data).

Database updates

Successful persistence is done by updateWellnessOnboardResponseToDB(). The method expects the Visit API response to contain a referenceId. That value becomes the new wellness_onboard value for every member in the family.

BeforeAfter successful onboard
employee_polices.wellness_onboard = '0' employee_polices.wellness_onboard = {referenceId}

The code collects all row updates for the page and writes them in one updateBatch(..., 'id') call, using each memberDetails[].memberId as the primary key.

$allUpdates[] = [
    'id'               => $memberPk,
    'wellness_onboard' => $referenceId,
];

$this->employeePolicyModel->updateBatch($allUpdates, 'id');

Failure behavior

ConditionCurrent behavior
Missing client_policy_id in job payload Logs an error and returns true so the worker can continue.
No rows found for a page Logs batch completion and stops queue recursion.
Row missing emp_code Skips that row and logs a warning.
HTTP status 400, 500, or missing response data Logs the API error and does not update wellness_onboard.
Missing referenceId in API response Logs the issue and skips DB persistence for that family.
Queue context return value The job returns true instead of using $this->respond().
i
Current logging style The implementation uses multiple log_message('error', ...) calls for progress tracing, not only for failures. Keep that in mind while reading logs during QA or production support.

Developer steps

  1. Keep the browser trigger and route definitions in sync

    If you rename the route or change the method, update both Routes.php and the AJAX calls in employee_upload.php.

  2. Do not move the heavy loop back into the web request

    initiateWellnessOnboard() should remain a thin queue trigger. The batching work belongs in the job handler.

  3. Preserve family grouping assumptions

    emp_code is the family key. If source data changes, make sure all related members still group together correctly.

  4. Validate required member data before rollout

    Fields such as emp_code, relationship, gender, dob, mobile, and email_corporate affect payload quality.

  5. Keep the queue mapping intact

    If you rename initiateWellnessOnboardJob, update the corresponding entry in JobWorker::$event_class_mapping.

  6. Verify runtime configuration before testing

    The queue worker must be running, and both wellness environment variables must be present before QA can validate the end-to-end flow.

Common pitfalls

PitfallWhy it happens
The Visit onboard button never appears The status endpoint returned 0 because the selected policy failed one of the eligibility filters.
User sees Process started but no records change The initial request only queues the job; no worker means no real processing.
Only some members get updated Families with API errors, missing referenceId, or missing emp_code are skipped during persistence.
Spouse records fail unexpectedly mapRelationship() requires gender to translate spouse into husband or wife.
Count shown in UI does not equal number of API calls The UI count is member-row based, but outbound requests are family-group based.
Pagination changes create odd onboarding batches The job paginates raw rows first and groups families afterward, so careless query changes can alter how families are chunked.