Write paths: who writes user_bans rows and how
Four things write user_bans. The overview is User bans overview. File and line numbers are from PORTAL_V2_BACKEND_GLOBAL at commit 43b693a (origin/merge-prod, 2026-09-16).
Three rules hold for every write path:
- Nothing sends an email, an SMS or a push notification. Not to the jodder, and not to the hiring manager.
- Nothing cancels a shift the jodder is already selected for (
job_user.status: 2). Ops use the existing reject flow for that. jod_jobs.no_users_apply_jobchanges in one place only. Creating a ban cancels the jodder's waiting applications, and each cancelled application lowers it by 1. That is the same rule as a withdrawal. Nothing else touches it.
1. Ops create a ban
Endpoint: POST /portal/user-bans. The method says the action. The path names the record. Do not copy the /index and /approve/{id} path style of the cancellation penalties routes. Copy everything else from that feature:
| Part | Copy from |
|---|---|
| Routes | routes/api.php:479-486 |
| Controller | app/Http/Controllers/CancellationPenaltiesController.php |
Request class with checkPermission | app/Http/Requests/IndexCancellationPenaltyRequest.php |
Service with DB::transaction and lockForUpdate | app/Services/CancellationPenaltyService.php:94-146 |
| Permission rows | database/seeders/AddCancellationPenaltyFeaturePermissionSeeder.php |
| Lang keys | resources/lang/en/messages.php:329-341 and the vn file |
The form shows the jodder's waiting applications at the chosen client and outlet. It loads them from GET /portal/applicantuser/{id}/pending-applications once the three pickers are set. The warning line above Submit shows how many the ban will cancel. See Read paths: the applications a ban would cancel.
Request fields and validation:
| Field | Rule |
|---|---|
user_id | Required. A users row with user_type: APP. |
company_id | Required. A companies row. |
location_id | Optional. Must belong to company_id. Empty means every outlet. |
banned_reason | Required text. |
Inside one DB::transaction closure:
- Lock and check that no active row exists for the same
user_id,company_idandlocation_id. If one exists, return an error. MySQL cannot enforce this with a unique index, because many rows may share aNULL. - Insert the row with
source: portal,banned_by= the current user,banned_at= now. - Cancel the jodder's waiting applications at that client. The next heading says how.
Nothing else goes inside the closure. No HTTP call. No event.
After the closure returns: dispatch RecalculateApplicantRankingJob once per job whose application was cancelled. The other applicants on those jobs then get new ranks. The ops-disable path does the same at app/Services/JodJobService.php:2789.
Creating a ban cancels the jodder's waiting applications
Creating a ban cancels every application the jodder has waiting at that client. "Waiting" means job_user.status: 1 on a job with jod_jobs.status: 1 (OPENING). Selected shifts stay as they are.
Which rows: the job_user rows where all of these hold.
| Condition | Column |
|---|---|
| It is this jodder | job_user.app_user_id = the banned user |
| The application is waiting | job_user.status: 1 |
| The job is still open | jod_jobs.status: 1 |
| The job is at this client | jod_jobs.company_id = the ban's company_id |
| The job is at this outlet, when the ban has one | jod_jobs.location_id = the ban's location_id. Skip this condition when the ban's location_id is NULL. |
What each row gets:
| Column | Value |
|---|---|
job_user.status | 14, Constants::JOB_USER_STATUS_ADMIN_DISABLING_APPLICANT_REJECTED. The ops-disable path already uses it for waiting applications. The reports and the candidates filter already know it. |
job_user.system_rejected_datetime | now |
job_user.applicant_rank and job_user.applicant_rank_detail | NULL |
jod_jobs.no_users_apply_job | one less, on each affected job |
Do it as one statement. Put the five conditions above in one repository method, waitingApplicationsAtClient($userId, $companyId, $locationId), that returns a query builder. The pending-applications endpoint calls ->get() on it. This step calls ->decrement(...) on it. Copy JobUserRepositoryEloquent::cancelAppliedOpenJobsBeforeSelectionByUserId (app/Repositories/JobUserRepositoryEloquent.php:248) for the update. That method does four things:
- joins
jod_jobs - filters on the same three status conditions
- calls
->decrement('jod_jobs.no_users_apply_job', 1, [...]) - passes the
job_usercolumns to set in that same call
Add the company and outlet conditions. Select the affected job_id values before the update, so the code can dispatch RecalculateApplicantRankingJob for each one after the transaction is saved.
The set is small: one jodder's waiting applications at one client. The lookup starts on the primary key of job_user.
What the jodder sees: the job leaves their "pending" list. That list shows only job_user.status: 1 on open jobs (app/Repositories/JodJobRepositoryEloquent.php:383-387). No message is sent. The first time they hear anything is when they apply again. Then they see the message under user_banned_at_location or user_banned_at_company.
What this means for the read paths: after the ban is saved, the jodder has no waiting application at that client. The hiring manager's applicant list and the jobs pending selection page have nothing of theirs to show. jod_jobs.no_users_apply_job already matches. The select check and the auto-select condition stay as second checks for a page opened before the ban.
Who may call the endpoint: the permission seeder for cancellation penalties grants SUPER_ADMIN and SG_OPS. Decide whether INTERNAL gets it too.
The create form on the ops page needs three inputs: a jodder, a client, and an outlet or "all outlets". The jodder search already exists at GET /portal/applicantuser/index. It matches name, email and phone. It does not match NRIC.
2. Ops lift a ban
Endpoint: PATCH /portal/user-bans/{id}/lift.
| Field | Rule |
|---|---|
lift_reason | Required text. |
Inside one DB::transaction closure:
- Lock the row.
- Check
lifted_at IS NULL. If not, return an error. - Set
lifted_at= now,lifted_by= the current user, andlift_reason.
A lifted row never becomes active again. To ban the same jodder again, ops create a new row.
There is no update endpoint. user_id, company_id and location_id cannot change. Ops lift the ban and create a new one instead.
Effect: the jodder can apply again at once. The applications cancelled when the ban was created stay cancelled. The jodder applies again if they still want the job.
The list endpoint, GET /portal/user-bans, is a read. The read paths page describes it: Read paths: the ops list.
3. The import command bans:import
Ops track bans today in a Google Sheet with four columns: date, full name, location name, feedback. It has no ids. Some location values are "all McDonalds", "MCD" and "all four points outlets". The last one is a different client.
The command: php artisan bans:import {path} --dry-run. Copy the shape of app/Console/Commands/ImportBulkJodJobsCommand.php. That command takes a path and has a --dry-run flag.
Steps:
- Ops export the sheet as CSV and add a company column. One file per client is fine.
- The file goes to the prod server by hand. It is never committed to git. Committing CSV files under
resources/csv/is right for outlet lists. It is wrong for a file of jodder names and client complaints. - The dry run writes a report. One line per input row: the matched user ids, the matched location id, and a status.
- Ops fix the names in the sheet and run the dry run again, until every row has exactly one match.
- The real run inserts the rows.
Matching rules:
| Input | Rule |
|---|---|
| Full name | Compare case-insensitively against first_name plus last_name of users with user_type: APP. Prefer users with any job_user row at the target client. Zero or many matches means the row is not written. |
| Location name | Compare against locations.name for that client. "All", "MCD" and the client's name mean location_id: NULL. |
| Date | Becomes banned_at. |
| Feedback | Becomes banned_reason. |
Each written row has source: seed and banned_by: NULL. Each written row also cancels the jodder's waiting applications at that client. It does that through the same service method the portal uses, and dispatches the same RecalculateApplicantRankingJob runs. The command skips a row when an active ban with the same user, client and outlet already exists. So it is safe to run twice.
The audit package does not write audits rows for console commands (config/audit.php:168). That is why source and banned_at are columns.
4. The migration
The migration creates user_bans and nothing else. Copy database/migrations/2024_08_31_132226_create_eber_points_logs_table.php for the column types and the foreign key style.
Two things the migration does not do, on purpose:
- It does not move user 100641 out of
JodJobService::SOFT_EXCLUDED_APPLICANT_IDS. That user is hidden from every client. This table holds bans for one client each. A jodder who must be kept off every client is suspended instead. What to do with that constant is a separate decision. - It does not add an "every company" row.
company_idis required.
Add a rule for user_bans to database/clean_room/manifest.csv in the same pull request. That file tells the nightly clean-room copy which columns to blank. Without the rule, the drift check fails the PR. banned_reason and lift_reason are free text with names, so the rule must blank both.