Module Research Notes
Evidence gathered from the current module files before writing the user guide pages.
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, viewJob_interviewsJob_offersJob_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_formJob_positionsRecruitmentRecruitments_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
| Route | Target |
|---|---|
hrs/recruitment | recruitment/index |
hrs/recruitment/(.+) | recruitment/$1 |
hrs/job_positions | job_positions/index |
hrs/job_positions/(.+) | job_positions/$1 |
job-portal | job_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).