Read paths: where the code checks user_bans
This page lists every place that reads user_bans. Three places check whether a jodder is banned. Two endpoints serve the ops page: one lists bans, one lists the applications a ban would cancel. File and line numbers are from PORTAL_V2_BACKEND_GLOBAL at commit 43b693a (origin/merge-prod, 2026-09-16). The overview is User bans overview.
The one SQL condition that finds an active ban
Write this once. Put it in a query scope on JobUser, or in a method on a new UserBanRepository. Every place below calls it.
-- Is this applicant banned for this job?
-- Bind jod_jobs.company_id and jod_jobs.location_id.
EXISTS (
SELECT 1 FROM user_bans b
WHERE b.user_id = job_user.app_user_id
AND b.lifted_at IS NULL
AND b.company_id = ?
AND (b.location_id IS NULL OR b.location_id = ?)
)
| Use | How |
|---|---|
| Check one jodder for one job | Bind the job's company_id and location_id. Places 1 and 2. |
| Remove banned rows from a query | WHERE NOT EXISTS (...). Place 3. |
Cost: user_bans will hold a few hundred rows. The condition is one lookup on the index user_bans_lookup per applicant row. A McDonald's job had 1.8 applications on average in August 2026. The largest had 18.
The three places that check user_bans
Creating a ban cancels the jodder's waiting applications at that client. See Write paths: ops create a ban. So after a ban, the jodder has nothing waiting there. Place 1 is the rule that matters in normal use. Places 2 and 3 are second checks. They cover the short time between a ban and a page or a cron run that started before it. Each costs one index lookup.
1. Jodder applies: the apply endpoint rejects a banned jodder
- Endpoint:
POST /job/applicantApplyJob - Code:
JodJobService::applyJobService,app/Services/JodJobService.php:1394-1497
After the identity checks at lines 1417 to 1449, run the condition for the current user and the job. Then:
user_bans row found | What the code does |
|---|---|
| None | Continue as today. |
One, with location_id set | Return ['status' => false, 'error' => 'user_banned_at_location']. Write no row. |
One, with location_id: NULL | Return ['status' => false, 'error' => 'user_banned_at_company']. Write no row. |
Both keys are new. Add them to the jobs block of resources/lang/en/messages.php, next to job_can_not_apply on line 78. Mobiles\JodJobController reads that block, because its controllerTitle is jobs (app/Http/Controllers/Mobiles/JodJobController.php:93). Add the same keys to resources/lang/vn/messages.php. Without them, the Vietnam instance shows the raw key.
| Key | English text |
|---|---|
user_banned_at_location | We're sorry, this outlet is not accepting your applications right now. Other outlets are waiting for you, so please keep applying. |
user_banned_at_company | We're sorry, this employer is not accepting your applications right now. Other employers are waiting for you, so please keep applying. |
| Vietnamese, both keys | Ask a Vietnamese speaker on the team. Keep the same meaning and the same length. |
There are two keys because the advice differs:
- an outlet ban leaves the client's other outlets open to the jodder, so the message points them there
- a company-wide ban does not, so the message points them to other employers
Neither text says "banned". Neither text names the client. Support will get questions from jodders who see them. Ops need an agreed answer before this ships.
JodJobService::SOFT_EXCLUDED_APPLICANT_IDS and its three readers stay as they are. The readers are at lines 1415, 1518 to 1523, and 1546 to 1549. That user is not a per-client ban. See Write paths: the migration.
What the jodder sees when the apply endpoint rejects them
Both apps show the server text as it is. Neither app sends a language header, so the jodder sees the English text. I checked the mobile app at branch prod (commit 7935b6bd4, 2026-08-05) and the web applicant app at branch main (commit 420449d68, 2026-08-06).
| App | Code that shows the text | What the jodder sees |
|---|---|---|
Mobile app, JOD_MobileApp_V3_GLOBAL | src/common/handleMsg/index.ts:25-46 reads error.message. src/library/components/SnackBar/SnackBarItem.tsx:148-149 renders it. | A red bar at the top of the screen for 3 seconds, with the message and no title. The 3 seconds come from DURATION_HIDE in src/library/components/SnackBar/constants.ts:1. That value applies to every message in the app. |
Web applicant, jodgig-web-applicant | app/errors/api-error.js:69-95 reads error.message. app/domains/jobs/job-actions.jsx:125-133 shows the toast. | A red toast titled "Application Error" for 10 seconds, with the message. |
After the toast, both apps behave the same way:
- the jodder stays on the job page
- the Apply button stays enabled
- the app does not fetch the job again, and the job stays in every list
- the jodder can tap Apply again and gets the same toast again
Nothing on either client knows about bans. Neither app reads an error code. Neither app matches on the message text. So the message text alone tells a ban apart from a closed job, and an outlet ban apart from a company-wide one. Two sentences is the most a jodder can read in 3 seconds. A longer message needs a mobile change first: a longer duration for this one message, or a dialog instead of the bar.
Two rules follow from this:
| Rule | Why |
|---|---|
| The apply endpoint returns HTTP 400 for a banned jodder. Never 401 or 403. | On 401 or 403 the mobile app deletes the login token. It then shows "Your login session has expired. Please login again." (src/api/job/requests/postApplyJob.ts:38-41). |
| The message text is the only thing the jodder sees. | If we later want the Apply button hidden for a banned jodder, the job detail response needs a new field. The mobile app already hides the button on job.status and on userStatus (src/features/authentication/job_detail/index.tsx:904-913). That is not part of this feature. |
2. Hiring manager selects: the select endpoint rejects a banned id
- Endpoint:
POST /portal/jodJob/approveApplicantSelectForJob - Code:
JodJobService::approveApplicantSelectForJobService,app/Services/JodJobService.php:2459-2533
Next to the suspended check at lines 2489 to 2495, run one query with whereIn over every id in the request. If any id is banned for this job, return ['status' => false, 'error' => 'error_approve_applicant']. That key already exists.
This runs before DB::beginTransaction() at line 2506. Do not add the check inside the loop after that line. A return there would leave the transaction open.
Why the check is needed:
- the endpoint accepts any id with
job_user.status: 1(line 2533) - it does not read the applicant list
- a manager's page opened before the ban still holds the id
- one query covers that case
3. Auto-select cron: the query that picks the top applicant excludes banned jodders
- Commands:
command:job-auto-select-applicantandcommand:job-short-notice-auto-select-applicant, both at 09:00 and 17:00 (app/Console/Kernel.php:328-356) - Code:
AutoSelectApplicantService::determineTopApplicant,app/Services/AutoSelectApplicantService.php:232-244
Replace the whereNotIn at line 237 with the condition as a WHERE NOT EXISTS. Both commands share this method.
The check must be inside the query. The method takes the first row by applicant_rank. The code after it checks that row five times (lines 47 to 107):
- user type
- user status
- UKG
- time clash
- still applied
If any check fails, the code rolls back and returns. It does not try the second row. So a PHP if after the query would leave the job unfilled.
Three more facts about this query:
- After a ban cancels the waiting applications, the cron normally finds nothing of a banned jodder's. The condition covers a run that loaded its jobs before the ban was saved.
applicant_rankisNULLuntilRecalculateApplicantRankingJobruns. MySQL sortsNULLfirst. So excluding banned jodders from that job would not help. It would put them first.- Both commands throw away the result of each job. Write one log line per job where the condition removed at least one applicant. That line is the answer when a client asks what the system did.
The ops list: GET /portal/user-bans
This endpoint does not check a ban. It shows bans to ops. It is the read side of the ops page. The write paths page has the create and lift endpoints.
Query parameters:
| Parameter | Rule |
|---|---|
company_id | Optional. Only bans for this client. |
location_id | Optional. Only bans for this outlet. |
status | Optional. active returns rows with lifted_at: NULL. lifted returns the rest. Default: active. |
page, limit | The same as every other list in this repo. config/portal.php:123-127 has the defaults. |
The query in Laravel:
$page = UserBan::query()
->with([
'user:id,first_name,last_name',
'company:id,name',
'location:id,name',
'bannedBy:id,first_name,last_name',
'liftedBy:id,first_name,last_name',
])
->when($filter['company_id'] ?? null, fn ($q, $id) => $q->where('company_id', $id))
->when($filter['location_id'] ?? null, fn ($q, $id) => $q->where('location_id', $id))
->when(($filter['status'] ?? 'active') === 'active', fn ($q) => $q->whereNull('lifted_at'))
->when(($filter['status'] ?? 'active') === 'lifted', fn ($q) => $q->whereNotNull('lifted_at'))
->orderByDesc('banned_at')
->paginate($filter['limit']);
Why eager loading with with(), and not a join:
- the list needs
usersthree times: the jodder, the ops user who banned, and the ops user who lifted. A join would need three aliases and would return every user column three times. with()runs one extra query per relation. Each one isWHERE id IN (...)on a primary key. That is 7 queries per page, whatever the page size: 1 count, 1 page ofuser_bans, 5 relations.paginate()countsuser_bansrows only. A join with duplicated rows would break that count.- the repo already does this in
CancellationPenaltyService::index(app/Services/CancellationPenaltyService.php:24-86).
One trap: User::$appends (app/Entities/User.php:86-100). Turning a User model into JSON runs those accessors. Some of them run a query per user, for example certificate_urls. Loading only three columns does not stop that. So build the response array by hand, as JodJobService::slotUserReviewService does at lines 6203 to 6217. Take id, first_name and last_name from each loaded user, and nothing else.
The relations on the new UserBan entity. Name them the way CancellationPenalty does at app/Entities/CancellationPenalty.php:37-75:
| Relation | Type | Key |
|---|---|---|
user() | belongsTo User | user_id |
company() | belongsTo Company | company_id |
location() | belongsTo Location | location_id |
bannedBy() | belongsTo User | banned_by |
liftedBy() | belongsTo User | lifted_by |
The JSON shape. The outer object is the pagination array this repo writes by hand (CancellationPenaltyService.php:76-85). Each element of data is one ban. The ban's own columns come first. Each related record is a nested object under the ban, because that is the relationship. Never flatten a related record into columns like user_first_name.
{
"total": 12,
"limit": 20,
"page": 1,
"last_page": 1,
"data": [
{
"id": 1,
"status": "active",
"company_id": 151,
"location_id": null,
"banned_reason": "Left the shift twice without telling the manager",
"banned_at": "2026-09-22 10:15:00",
"source": "portal",
"lifted_at": null,
"lift_reason": null,
"company": { "id": 151, "name": "McDonald's" },
"location": null,
"user": { "id": 555, "first_name": "Jodder", "last_name": "Five" },
"banned_by": { "id": 42, "first_name": "Ops", "last_name": "User" },
"lifted_by": null
}
]
}
Three notes on the shape:
statusis not a column. The service computes it:activewhenlifted_atisNULL, otherwiselifted. The frontend readsstatusand never computes it fromlifted_at.locationisnullfor a company-wide ban. The frontend shows "All outlets" for it.banned_byisnullfor rows thatbans:importwrote. The frontend shows "Imported" for it.
Index: the filters use user_bans_scope (company_id, location_id, lifted_at). Ordering by banned_at on a few hundred rows needs no index.
The applications a ban would cancel: GET /portal/applicantuser/{id}/pending-applications
The create form shows ops what the ban will cancel before they press Submit. This endpoint returns that list. It is a read. It changes nothing.
| Parameter | Rule |
|---|---|
{id} | The jodder. A users row with user_type: APP. |
company_id | Required. The client. |
location_id | Optional. The outlet. Empty means every outlet of the client. |
The rows are exactly the rows that creating the ban would cancel. See Write paths: creating a ban cancels the jodder's waiting applications. To keep the two in step, put the conditions in one repository method that returns a query builder. This endpoint calls ->get() on it. The create endpoint calls ->decrement(...) on it. The conditions can then never differ.
$rows = $this->jobUserRepo
->waitingApplicationsAtClient($userId, $companyId, $locationId)
->with([
'job:id,job_title,job_start_date,job_end_date,company_id,location_id',
'job.company:id,name',
'job.location:id,name',
])
->orderBy('jod_jobs.job_start_date')
->get();
No pagination. One jodder's waiting applications at one client is a short list. Build the response array by hand, for the same User::$appends reason as the ops list.
{
"data": [
{
"job_id": 9001,
"applied_at": "2026-09-20 18:02:11",
"applicant_rank": 1,
"job": { "id": 9001, "job_title": "Crew", "job_start_date": "2026-10-03 08:00:00", "job_end_date": "2026-10-03 16:00:00" },
"company": { "id": 151, "name": "McDonald's" },
"location": { "id": 2301, "name": "McDonald's Bendemeer" }
}
]
}
applied_at is job_user.apply_date. The frontend shows the count of data in the warning line above the Submit button.
What does not check user_bans
| Place | Why not |
|---|---|
The hiring manager's applicant list, UserRepositoryEloquent::searchApplicantUserApplyJodJobByJobId (line 383) | A banned jodder has no waiting application at that client after the ban. There is nothing to hide. The existing whereNotIn for SOFT_EXCLUDED_APPLICANT_IDS at line 415 stays. |
The jobs pending selection page, JodJob::highRankApplicant (app/Entities/JodJob.php:114-122) | Same reason. It lists job_user.status: 1 rows. A cancelled application has status: 14. |
RecalculateApplicantRankingJob and JodJobRepositoryEloquent::getApplicantsByJobId (line 3118) | Creating a ban clears the banned jodder's rank and runs this job again for each affected job. Nothing else is needed. Excluding jodders here would set applicant_rank: NULL, which sorts first. |
Jodder withdraws, JodJobService::cancelPreJob (line 2270) | A cancelled application has status: 14. The withdraw code only acts on status: 1. There is nothing to withdraw. |
The job card's applicants relation | It lists selected jodders only. |
| Reminder emails and SMS | They read jod_jobs.no_users_apply_job. Creating a ban already lowered it. |
The candidates list across jobs, JodJobRepositoryEloquent.php:1586-1627 | It shows a banned jodder's name next to their old applications. Hiding it is a small later change if ops ask. |