Recruitment Reference Public

Module Research Notes

Evidence gathered from the current Recruitment module files — controllers, models, configs, and migrations — before writing these user guide pages.

Guide version: r1 Module version: 1.0.0 Updated: 2026-07-30 Estimated time: 14 min
You are viewing version r1 of this guide. View the current version
Module Research Notes

Recruitment

Evidence gathered from the current module files before writing the user guide pages.

Slug: recruitmentVersion: 1.0.0Category: Human ResourceReviewed: 2026-07-11

Research Sources Inspected

Source path

modules/Recruitment

Feature flags marked true

  • Controllers
  • Models
  • Views
  • Language
  • Permissions
  • Left Menu
  • Default Menu
  • Routes
  • Migrations
  • Cron Jobs
  • Helpers
  • Api Endpoints

Evidence Summary

Manifest

module.json defines slug recruitment, version 1.0.0, category Human Resource, and the module capability flags used in this guide.

Runtime surface

7 controller file(s), 37 view file(s), and 7 route mapping(s) were found.

Data surface

10 model file(s), 10 declared table(s), and 4 migration file(s) were found.

Operations surface

18 permission key(s), 1 job(s), 1 seeder(s), and 1 test file(s) were found.

Detailed File Evidence

Controllers and methods

  • Applicants_api - index, view
  • Job_interviews
  • Job_offers
  • Job_portal - index, list_data, quick_view, view, list_random_data, companies, company, modal_form, saved, saved_jobs_data, saved_jobs_summary, upload_file, validate_file, check_application_email, submit_job_application, report_job_modal_form
  • Job_positions
  • Recruitment
  • Recruitments_api - index, view, applicants

Jobs and schedules

  • Purge stale applicant data (retention policy) (0 3 * * *) - Anonymizes rejected applications and applicant accounts older than the configured recruitment_retention_months (0/unset = disabled). RA 10173 data-minimization.

Routes sampled

RouteTarget
hrs/recruitmentrecruitment/index
hrs/recruitment/(.+)recruitment/$1
hrs/job_positionsjob_positions/index
hrs/job_positions/(.+)job_positions/$1
job-portaljob_portal/index
job-portal/(:any)job_portal/$1
job-portal/(:any)/(:any)job_portal/$1/$2

README topics

  • Layout
  • End-to-end hiring flow (Phases 2-5)
  • PH compliance (Phase 6)
  • How it loads
  • Data
  • Permissions
  • Tests
  • Demo seed
  • Related docs

Operating Implications

  • Permission coverage is part of the module contract, so every workflow should be tested with a role-limited user and an administrator.
  • The module has migrations, so fresh installs and tenant databases need migration validation before go-live.
  • The module has a public or portal-facing surface, so privacy review and logged-out testing are required.
  • The module has API documentation or routes, so response examples and query filters should stay aligned with user-facing screens.
  • Scheduled jobs can change statuses, notifications, or summaries after users leave the screen; support should check cron status during troubleshooting.
  • Seeders exist for sample or starter data; operators should know whether seeded records are demo-only or operational defaults.

Known Risks from the Manifest

  • SLUG = PRE-EXISTING SETTING: slug 'recruitment' matches the legacy module_recruitment active-setting (seeded '1' in install/migration/schema.php), so existing tenants keep their on/off state and NO settings-seed migration was needed (unlike Compensation, whose master setting was new).
  • PUBLIC PORTAL IS UNGATED BY DESIGN: Job_portal extends GuestController with NO with_module gate - identical to the legacy core controller. The portal is the PLATFORM-WIDE careers site aggregating open jobs across every tenant DB; a single tenant's module_recruitment toggle only gates that tenant's back-office ATS (controllers/Recruitment.php constructor), sidebar item, Settings tab and RBAC rows - never the public site. Module routes stay injected while disabled (runbook G6), which exactly preserves the legacy unconditional core routes.
  • CMS CORE-PAGE DECLARATION STAYS IN CONTENT: the job portal's CMS declaration (route_key 'core.job_portal') remains in modules/Content/config/core_pages.php + cms.php route_keys and was deliberately NOT moved to a modules/Recruitment/config/public_pages.php. Two verified reasons: (1) existing tenant cms_menu_items rows from the core-page picker are type='core_page' keyed 'core.job_portal', and CmsMenuSourceRegistry has no cross-type resolution - moving the declaration would silently orphan those menu items on every tenant; (2) module public_pages declarations are skipped for DISABLED modules (module_public_pages_raw gates on the module flag), so Sync Public Pages would stop ensuring the route on tenants with module_recruitment off - a regression, since the public page renders regardless of the flag. Revisit only with a data migration for cms_menu_items.
  • PERMISSIONS (done): the 3 parent RBAC rows (recruitment [5][6]-auto-expanding, manage_recruitments, manage_applicants) moved from application/controllers/Roles.php to config/permissions.php. The bootstrapped merge reproduces only the [5] CRUD auto-expansion, never the [6] category expansion, so the full 10-key set (parent + 3 CRUD + recruitment_category + its 3 CRUD children) is declared EXPLICITLY with the exact categories/actions the core loop generated - stored role grants and every granular gate keep working. staff_file (applicant resume download gate used by the ATS controller) is a core Staffing key and stays in Roles.php. ACCEPTED STANDALONE COST (identical to Compensation/Finance/Production): the merge is gated on module_enabled('module_recruitment'), so with the module DISABLED the rows leave the RBAC editor and re-saving a role in that state strips its stored recruitment grants (pre-extraction the unconditional core rows survived disabled-state saves); runtime with_permission still reads stored grants and the setting is seeded enabled, so this only bites in the disabled state.
  • LANGUAGE (done, runbook G3 + global-namespace de-collision): language/english/job_portal_lang.php moved wholesale, then adjusted because MODULE lang files load GLOBALLY on every request (last-wins over core and over alphabetically-earlier modules) - the legacy file was scoped to 3 controllers via lang->load('job_portal'), so its generic keys never leaked. Applied: (a) dropped every key core default_lang.php already defines with an IDENTICAL value (select, comma_separated, prefer_not_to_say, single, married, widowed, clone, progress, other, linkedin_url); (b) re-homed 'flexible' (genuinely absent from core, consumed by Appointments) to default_lang.php; (c) renamed the 3 VALUE-CONFLICTING keys module-scoped so no other surface's rendered text changes: jp_month ('month' - core has "Month", used by Finance/Compensation report headers), jp_copy_link ('Copy link' - core has "Copy Link", used by marketing Forms), jp_submit_application ('Submit Application' - Learn's learn_lang has 'Submit application' on the university apply button and Recruitment loads after Learn). A module test pins that none of these shared keys are redefined here. The 3 email-template label keys (job_application_received, report_job_received, report_company_received - consumed dynamically by the core Email Templates UI via lang($template_name)) stay in the module file: they resolve while the module is installed; after a physical uninstall those template rows would show raw keys (accepted cosmetic degradation).
  • CORE GLUE - REDUCED TO THE MINIMUM THE PLATFORM SUPPORTS (v1.3.0): moved OUT of core via supported sidecars: the Settings-sidebar tab registration (config/settings.php, Projects precedent - the hardcoded tabs.php block is gone; note the module-merged entry renders at the END of the components group, a cosmetic order change) and the job_hiring/job_application allowed_labels (config/control_tokens.php, Finance precedent). STILL CORE (each is platform-structural, same acceptance as Compensation/Finance): (a) the Settings::recruitment* CONTROLLER METHODS + their views under application/views/settings/components/recruitment* - settings/<component> URIs route to the core Settings controller; Settings.php loads this module's Recruitment_statuses_model / Recruitment_banner_model via the globally-registered package paths (ungated). (b) application/config/system_logs.php keeps ONLY the 6 legacy defs for actions LOGGED BY core Settings.php (recruitment settings/status changes) - log defs live with their logger; the module-era defs (job_position, job_offer, hire, bfoq) ship in config/system_logs.php. (c) adsense_list_data 'recruitment' page row in application/config/settings.php (page still exists, Procurement precedent). (d) the hardcoded 'Job Portal' link in application/views/includes/public/footer.php + sitemap entries. (e) default_lang.php keeps the job-positions/retention vocabulary because CORE surfaces consume it (Team_members position pickers, the Settings retention field) - runbook G3. (f) the 20260704171017 job_positions migration stays in application/migrations/ - it ALTERs the core-shared team_member_job_info (G5) and already ran against the core migrations table; the job_positions TABLE is module-owned going forward but was historically created there. (g) Team_members' position-picker wiring - a core->module reverse coupling identical to Settings', guarded on table existence.
  • MANAGE MODULES: the legacy 'recruitment' row was REMOVED from $config['module_list_data'] (application/config/settings.php) - the dynamic module list supplies it from config/module_config.php (module_key 'recruitment', category 'Human Resource', so the Manage Modules grouping is unchanged). The deprecated $config['default_staff_menu'] 'recruitment' row was intentionally LEFT in place: it only feeds the stale-compiled-config degraded fallback and filter_items_by_active_modules() drops the item whenever the module is inactive (same treatment as Finance's wage_summary row).
  • MENU (v1.3.0 - OWN GROUP): the module now owns a dedicated 'Recruitment' sidebar category (config/menu.php group, master-gated module_recruitment) with children job_postings (the ATS, url hrs/recruitment - RENAMED from the old flat 'recruitment' item so the child never shares the group header's name, which the merger dedupes), job_positions (url hrs/job_positions) and job_portal (the public careers site). config/default_menu.php ships the matching 'Recruitment' group at position 25 (right after core Human Resource=20) - it no longer coalesces into the core Human Resource header. CUSTOMIZATION IMPACT: users who pinned the old flat 'recruitment' item in a customized menu lose that item by name (the live set no longer contains it); the new items appear in the editor's Available panel and 'Restore to default' seeds the new group. All hardcoded Left_menu.php recruitment/job_positions blocks are deleted.
  • PUBLIC APIs (done): the two read-only Client API controllers moved from application/controllers/api/human-resource/ to flat module controllers renamed Recruitments_api / Applicants_api (App_Router resolves flat top-level module classes; *_api suffix per Compensation), with the route fragment in config/api_routes.php - URLs + route names unchanged. The recruitments:read / applicants:read scopes moved from core api_scope.php to config/api_scope.php (Finance/KpiMatrix precedent - scopes live where the endpoints live and drop from the grant catalog if the module is uninstalled). OpenAPI enrichment sidecars (api_query_filters / api_response_examples) are not shipped - endpoints appear in the generated spec via their route names (Compensation parity).
  • HRS PREFIX IS NOW AN ALIAS: the ATS controller physically left application/controllers/hrs/; config/routes.php maps hrs/recruitment[...] onto the flat module class, so every legacy URL - including the applicant_view deep-links embedded in already-sent job_application_received emails - keeps resolving. The alias MUST be the multi-segment 'hrs/recruitment/(.+)' (NOT (:any), which is single-segment in CI3): the legacy multi-segment URLs were previously resolved by the router's physical directory descent into application/controllers/hrs/, which no longer finds Recruitment.php - with (:any), every id-carrying deep link (view/{id}, applicant_view/{id}, kanban updates) 404s. Pinned by the module route test.
  • PRE-EXISTING MIGRATION GUARD GAP (unchanged): 3 of the 4 historical recruitment ALTER migrations in application/migrations/ (applicant_answers, hr_email, positions_required) guard with SHOW COLUMNS but not table_exists(); they would error on a database that skipped the base install schema. Unchanged by this extraction - the 6 owned tables always ship in install/migration/schema.php before migrations run.
  • GRADE-AWARE POSTINGS (Phase 3): job_hiring gained position_id / salary_grade_id / salary_min / salary_max / show_salary via the module migration. Every consumer is soft-gated: the posting modal renders the position picker only when the job_positions table exists (core HRIS migration), the grade picker only when is_module_activated('compensation'), and the band inputs only when the columns exist. CROSS-TENANT CAVEAT (pre-existing pattern): the public portal aggregates via SELECT * per tenant and silently drops tenants whose get_details throws - a tenant whose module migration has not run simply lacks the new columns (band renders '' via the empty(show_salary) guard); run migrate:modules per-tenant. The salary band renders with a fixed PHP + '/mo' format (PH-market portal; expected_salary in the wizard is likewise PHP-denominated).
  • JOB OFFERS (Phase 4): job_offers is module-owned; the offer PRICE is denormalized (offered_salary) - later salary-grade edits never mutate an outstanding offer. The public respond page (job-portal/offer/{uuid}) treats the UUID as the capability (no applicant login, mirroring the job-detail pattern); draft/withdrawn offers 404 rather than leak state; expiry is enforced at RESPONSE time (public GETs never write). Cross-tenant: Job_offers_model::find_by_uuid iterates tenant DBs (uncached - low traffic) and respond_offer switches to the owning tenant DB before writing (mirrors submit_job_application), so the timeline log, the job_offer_responded email template and company settings all resolve from the hiring tenant. The job_offer_sent/job_offer_responded templates seed via a CORE migration (shared email_templates table, G5).
  • HIRE CONVERSION (Phase 5): Recruitment::hire_applicant converts the applicant user IN PLACE (user_type applicant->staff, same user_id - preserves file paths + audit trail; a duplicate-email guard covers switching to a company email), writes team_member_job_info (+position_id when the column exists), sets employment_stage user-meta, optionally creates a DRAFT salary_history row (gated is_module_activated('compensation'); grade/step from the accepted offer; approval flows through the normal salary workflow, KpiMatrix-doc pattern), copies LOCAL application files into general_files under the team_members scope (S3-hosted application files are intentionally left on the application record - general-file S3 keys derive from the team_members path scope; documented degradation), and moves the application to the recruitment_status row whose title = 'hired' (the same title-match convention Recruitment_model's hired-count subquery uses; if no such status exists the stage is left unchanged). Hiring is gated manage_applicants + CORE staff_create.
  • MENU-LESS SUB-SURFACES: Job_offers and Job_interviews are AJAX-only flat controllers (no index action - bare URIs 404 at METHOD level by design, like Warehouse's headless engine controller); they have no sidebar entries (both live inside the applicant view), so menu.php/default_menu.php are intentionally unchanged by Phases 3-6.
  • PH-COMPLIANCE GATE (Phase 6, RA 10911/RA 6725): Recruitment::save blocks postings whose title/description match explicit age / sex / civil-status restriction phrasings (recruitment_validate_job_ad - conservative regexes, publisher liability) unless a BFOQ override checkbox + justification is recorded (logged bfoq_override:job_hiring, severity critical). The AGE question was REMOVED from the public apply wizard (RA 10911 section 5(a)) - historical age_range values remain in stored applicant_answers JSON but are no longer displayed; DOB is collected post-offer at onboarding.
  • CONSENT LEDGER (Phase 6, RA 10173): application_consents is the first-class per-application consent record (purpose, privacy_notice_version from setting recruitment_privacy_notice_version defaulting '1.0', separate UNTICKED-by-default talent_pool_opt_in, timestamp, CI-validated IP), written by submit_job_application beside the legacy inline answers copy (soft-guarded on table existence for pre-migration tenants). Ledger rows are intentionally KEPT by the retention purge - they are the proof of lawful processing.
  • RETENTION PURGE (Phase 6, RA 10173 section 11, review-hardened): jobs/PurgeStaleApplicantDataJob (slug recruitment_retention_purge, nightly 03:00, per-tenant) ANONYMIZES - never hard-deletes - terminally-rejected applications and their pure-applicant accounts once the per-tenant setting recruitment_retention_months (Settings -> Recruitment -> Configuration; whitelisted in core save_recruitment_config, where an EMPTY post explicitly persists '0' so clearing the field really disables the purge) has elapsed. DISABLED BY DEFAULT (0/unset). Guards: never hired/staff users, never talent-pool opt-ins, never users with newer/active applications; the application_consents table is a REQUIRED-tables gate (no ledger -> no purge - an unmigrated tenant can never purge against an unrecorded opt-in; the opt-in is ALSO mirrored into applicant_answers so it is never recorded nowhere). Failure-safety (adversarial-review fix): save() returns are CHECKED and the users-row flip (the candidate-query exclusion) happens in a second pass only after ALL of that user's application rows anonymized successfully - a silent production UPDATE failure (db_debug=false) can no longer strand un-anonymized PII behind the purged-email guard; failed rows are logged, counted and retried next run. Scope: also sweeps the user's SOFT-DELETED applications and blanks applicant-authored job_offers.response_notes for purged applications; application rows are kept (funnel stats) with a purge marker; LOCAL files unlinked, S3-hosted files skipped + logged (accepted, same S3 caveat as hire). ACCEPTED REMAINDERS: a user whose applications are ALL soft-deleted is never selected (staff explicitly deleted those records), and offers keep their business terms (only applicant-authored text is scrubbed). No statutory PH period exists, so the number is the tenant's documented policy (docs/01-JOB-PORTAL-ATS-RESEARCH.md section 4).
  • INTERVIEWS (Phase 6): job_interviews is a LEAN scheduler (scheduled -> completed/cancelled/no_show; invite email via interview_invitation, core seed 20260704183706 per G5) gated by the existing manage_applicants permission - deliberately NO new RBAC keys and NO Appointments-module dependency (self-scheduling/calendar sync stays backlog per the research doc P2).
  • TOOLS DEEP-LINKS (Phase 6): the applicant-view actions dropdown links four modules/Tools generators (interview questions/scorecard, offer letter, rejection email) gated is_module_activated('tools'), URLs site_url('tools/{slug}'). The tool pages themselves 404 unless synced+active+public in the Tools module - an inactive tool yields a 404 on click, accepted (no per-slug liveness check to keep the dropdown cheap).

Report a content problem