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.
app/Controllers/EmployeeController.php. The
queue worker entry is registered in app/Controllers/JobWorker.php.
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.
The current implementation is split across two browser-facing routes and one queued job handler:
| Entry point | Current 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.
Routes.php, the frontend AJAX calls, and any ACL
expectations together.
Both checkWellnessOnboardStatus() and
initiateWellnessOnboardJob() use nearly the same base query. A
member is considered eligible only when all of these conditions are true:
| Condition | Meaning 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.
checkWellnessOnboardStatus($client_policy_id) runs the eligibility query and returns count($data).
initiateWellnessOnboard($client_policy_id) inserts a job with the name initiateWellnessOnboardJob and payload ['client_policy_id' => ...].
JobWorker::$event_class_mapping maps that job name back to EmployeeController.
The job defaults to page = 1, per_page = 50, and calculates the SQL offset from those values.
Rows are grouped by emp_code before building API payloads.
The job posts each family payload, then stores the returned referenceId into all member rows for that family.
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.
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 field | Source in current code |
|---|---|
policyDetails.policyNumber | cp.policy_no |
policyDetails.employeeId | emp_code |
policyDetails.policyName | Hardcoded GMC |
policyDetails.policyStartDate | cp.policy_start_date |
policyDetails.policyEndDate | cp.policy_end_date |
policyDetails.plan | cp.wellness_plan_id |
policyDetails.source | Hardcoded NHANCE |
policyDetails.employer | clients.short_name |
memberDetails[].memberId | employee_polices.id |
memberDetails[].relationshipName | Mapped by mapRelationship() |
memberDetails[].gender | M => 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'],
],
],
]
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.
sendFamiliesToWellnessApi() uses the CI4 cURL service and reads
its runtime configuration from environment values:
| Env key | Usage |
|---|---|
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.
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).
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.
| Before | After 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');
| Condition | Current 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(). |
log_message('error', ...)
calls for progress tracing, not only for failures. Keep that in mind while
reading logs during QA or production support.
If you rename the route or change the method, update both Routes.php and the AJAX calls in employee_upload.php.
initiateWellnessOnboard() should remain a thin queue trigger. The batching work belongs in the job handler.
emp_code is the family key. If source data changes, make sure all related members still group together correctly.
Fields such as emp_code, relationship, gender, dob, mobile, and email_corporate affect payload quality.
If you rename initiateWellnessOnboardJob, update the corresponding entry in JobWorker::$event_class_mapping.
The queue worker must be running, and both wellness environment variables must be present before QA can validate the end-to-end flow.
| Pitfall | Why 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. |