v6.0.0
Recruitment module — Brazilian public-tender ("concurso público") candidate queue management. New [ffc_recruitment_queue] public shortcode, candidate-self [ffc_recruitment_my_calls] dashboard section, wp-admin "Recrutamento" submenu, full §14 admin REST surface (21 routes under ffcertificate/v1/recruitment), ffc_manage_recruitment capability + dedicated ffc_recruitment_manager role, atomic CSV importer (single-transaction wipe+reinsert with rollback on any validation error), two state machines (Notice draft→preliminary→active→closed and Classification empty→called→accepted→hired/not_shown with the §5.1 reopen-freeze rule that locks hired/not_shown once a notice has been reopened), convocation service (single + bulk + cancel with append-only call history), email dispatch on call create with masked PII placeholders, centralized §7-bis delete gating (candidate hard-delete only when zero classifications, classification individual delete only when empty + draft/preliminary), and a single serialized ffc_recruitment_settings option for email templates + cache + rate-limit + page-size knobs.
Static analysis hardening: PHPStan goes from level 7 (with a 231-entry baseline) to level 8 with zero errors and no baseline. Reaching that bar required typing every $wpdb->get_row / get_results return shape, removing twenty @deprecated Utils::* shim methods (with all 150+ call sites migrated), retiring two "remove in next major" legacy fallbacks plus a dead admin redirect, and hardening nullability across ~40 consumer files. Three dead onlyWritten properties masked by @phpstan-ignore are also gone, and a new CONTRIBUTING.md section documents the remaining (deliberate) PHPStan annotation patterns. The Submissions admin list also gains a "Move to form…" bulk action that lets operators reassign wrong-form submissions in one shot, with identifier-based conflict detection that keeps duplicates in the original form and reports their IDs back.
Added
- Recruitment module — schema (PR #80). Six new InnoDB tables under
{$wpdb->prefix}ffc_recruitment_*:adjutancy(global cargo registry, slug-keyed),notice(the edital, with uppercase-normalizedcode,statusENUMdraft|preliminary|active|closed,was_reopenedflag for §5.1 freeze, and apublic_columns_configJSON controlling the public shortcode's column visibility per notice),notice_adjutancy(N:N junction),candidate(standalone list with*_encrypted/*_hashpairs for CPF/RF/email + a NOT NULLpcd_hashHMAC over both PCD domains so "is PCD" is verifiable but not enumerable),classification(per(candidate, adjutancy, notice, list_type)row withrank/scoreand the empty→called→accepted→hired/not_shown state), andcall(append-only convocation history withcancellation_reason/cancelled_at/cancelled_bystamping cancelled rows in place). All declaredENGINE=InnoDB DEFAULT CHARSET=utf8mb4(transactional CSV import); UTC storage policy documented inline on every DATETIME accessor; logical FKs only (no DB-level constraints, matching plugin convention). - Recruitment module — repositories (PR #80).
RecruitmentAdjutancyRepository,RecruitmentNoticeRepository,RecruitmentNoticeAdjutancyRepository,RecruitmentCandidateRepository,RecruitmentClassificationRepository,RecruitmentCallRepository— each declares@phpstan-type *Rowaliases for its$wpdb->get_row/get_resultsshapes; consumers import via@phpstan-import-typeso the level-8 type chain stays end-to-end. CRUD round-trips, UNIQUE-conflict handling oncpf_hash/rf_hash(returns409 duplicate_cpf/duplicate_rfwithexisting_candidate_id), and the §3.5 hot-path index(notice_id, adjutancy_id, list_type, status, rank)for the "lowest-rank empty for this notice/adjutancy" query. - Recruitment module — services (PR #80).
CsvImporter(UTF-8 with optional BOM, English-only headers, atomicSTART TRANSACTION/COMMIT/ROLLBACKaroundwipe + reinsert; rejects punctuation in CPF/RF, comma decimals in score, divergent candidate fields across rows of the same CSV with the same CPF, adjutancy slugs not bound to the target notice, and any other validation error — the previous list survives intact on rollback),CallService(single + bulk-call atomic conditional UPDATEWHERE status = 'empty'; bulk is all-or-nothing inside a single transaction),PromotionService(preliminary→active snapshot or definitive_import branches; existing definitive rows wiped in the same transaction),DeleteService(§7-bis gating: candidate hard-delete iff zero classifications + reason; classification individual delete iffempty+ draft/preliminary;wp_usernever touched),PcdHasher(HMAC-SHA256(wp_salt('auth')|module-suffix, ("1"|"0") || candidate_id), both domains stored),EmailDispatcher(wp_mailon call create with HTML body + auto-derived text/plain alternative; placeholder substitution for{{name}},{{cpf_masked}},{{rf_masked}},{{email_masked}},{{adjutancy}},{{notice_code}},{{rank}},{{score}},{{is_pcd}}(i18n Yes/No → Sim/Não),{{date_to_assume}}/{{time_to_assume}}formatted inwp_timezone()). - Recruitment module — state machines (PR #80).
NoticeStateMachineenforces the §5.1 transition table (active→preliminary blocked once any call has ever been issued for the notice; closed→active flipswas_reopened=1one-way; preservedopened_at, overwrittenclosed_at).ClassificationStateMachineenforces the §5.2 table with the cross-aggregate reopen-freeze rule — oncenotice.was_reopened=1, every transition out ofhiredornot_shownis blocked for the rest of the notice's lifetime. Concurrency-safe via atomic conditional UPDATE; race losers receive409 recruitment_race_lost. Promotion is the only path that writes tolist_type='definitive'; preview and definitive lists are independent post-snapshot (the next promotion replaces definitive wholesale in the same transaction). - Recruitment module — REST controller (PR #80). 21 routes under
ffcertificate/v1/recruitmentcovering the §14 surface: notices (CRUD + status PATCH +public_columns_configvalidation rejectingrank=falseorname=false+promote-previewwith countdown completion), classifications (list + status PATCH with reason gating +call+bulk-call+cancel), candidates (list with?search=/?cpf=/?rf=/?notice_id=/?adjutancy_id=filters; CPF/RF normalized to digits then hashed server-side; sensitive fields decrypted in admin responses;409on duplicate CPF/RF or cross-vector user conflicts viaUserCreator::get_or_create_user()), adjutancies (CRUD +409on delete with referencing rows), candidate→classification assignment, andGET /me/recruitmentfor the candidate dashboard (grouped by notice, draft notices excluded, list_type matches notice's currently-exposed list, calls nested per-classification with cancelled history included).permission_callbackeverywhere — admin routes gate oncurrent_user_can('ffc_manage_recruitment'); the/meroute onis_user_logged_in(). Error codes namespaced withrecruitment_prefix. - Recruitment module — auth + settings (PR #80).
CapabilityManager::CONTEXT_RECRUITMENTconstant added; activation grantsffc_manage_recruitmenttoadministratorand registers the newffc_recruitment_managerrole (getsread+ffc_manage_recruitment). Single serialized optionffc_recruitment_settings(matches existingffc_settings/ffc_geolocation_settingsconvention) with seven sub-keys:email_subject,email_from_address,email_from_name,email_body_html,public_cache_seconds(default 60),public_rate_limit_per_minute(default 30),public_default_page_size(default 50). Per-notice PCD-badge visibility lives on the notice itself viapublic_columns_config.pcd_badge, not in global Settings. - Recruitment module — public shortcode (PR #81).
[ffc_recruitment_queue notice="EDITAL-…" adjutancy="…"]renders the public list server-side (no public REST).noticerequired;adjutancyoptional (omitted → adjutancy filter rendered at top when there are 2+ adjutancies). Status branching per §8:draft→ "Edital ainda não publicado." error;preliminary→ warning-only render ("Esta lista está em revisão.") with no listing at all (preview rows never exposed publicly);active/closed→ two-section layout (Não chamados =status='empty'on top, Chamados = everything else below, both ordered(rank ASC, candidate_id ASC), paginated independently via?page_top=/?page_bottom=).closedadds a "Edital encerrado." banner; the Chamados section showsdate_to_assume. Per-notice column toggles viapublic_columns_config(rank + name mandatory; status/pcd_badge/date_to_assume on by default; score/cpf_masked/rf_masked/email_masked off by default — masked CPF/RF/email rendered only when explicitly opted in per notice, viaDocumentFormatter). §8.4 error states wired (notice missing, notice unknown, adjutancy slug unknown, empty list). Server-side transient cache (TTLpublic_cache_seconds) + per-IP rate limit (cappublic_rate_limit_per_minute, both0to disable); rate-limit excess returns "Muitas requisições" message. - Recruitment module — admin UI + dashboard (PR #81). New wp-admin "Recrutamento" submenu under the existing plugin menu (gated by
ffc_manage_recruitment) with four tabs (Editais, Matérias, Candidatos, Configurações). Admin asset enqueue is screen-gated so unrelated admin pages remain untouched. Candidate-self section[ffc_recruitment_my_calls]for the user-dashboard page renders the §9 layout: visibility gate (only logged-in users with at least one classification viacandidate.user_id), per-notice grouping (drafts excluded), prévia/final banners depending onnotice.status, classification rows (rank + adjutancy + score + status), and a "Histórico de convocações" listing that includes cancelled calls (situação derived from call + classification: Convocado/Cancelado/Não compareceu/Contratado/Convocação revertida) with CPF/RF/email masked for the candidate's own view. uninstall.phpupdates (PR #80). Drops the six recruitment tables (children-first:call,classification,notice_adjutancy,candidate,notice,adjutancy), deletesffc_recruitment_settings, removes theffc_recruitment_managerrole, stripsffc_manage_recruitmentfrom all users (mirrors the existing$ffcertificate_capsloop), and clears theffc_recruitment_public_cache_*transient family. No data-preservation toggle (matches plugin convention).- Recruitment module — test coverage (PRs #80 + #81). 21 new test classes / ~229 tests / ~668 assertions across repositories (CRUD round-trips + UNIQUE conflicts), state machines (every legal transition + every blocked transition + the reopen-freeze cross-aggregate rule), CSV importer (happy path + every §6 rejection rule + transaction rollback), call service (single + bulk + concurrency race-loss + cancel/recall append-only history), delete gating (§7-bis rules 1 + 2), REST controllers (per-route permission_callback + payload validation + error codes), email dispatcher (placeholder substitution + masking + i18n Yes/No), public shortcode (each
notice.statusbranch + each §8.4 error state + cache hit + rate-limit excess), and the candidate dashboard section (anonymous + unlinked-user + draft-skip + prévia/final banner dispatch). - PHPStan repository row aliases. Each repository class declares
@phpstan-typefor the rows it returns from$wpdb->get_row/get_results; consumer classes import them via@phpstan-import-type. Coverage spans the four audience repositories (AudienceRepository,AudienceScheduleRepository,AudienceEnvironmentRepository,AudienceBookingRepository), the three reregistration repositories (ReregistrationRepository,ReregistrationSubmissionRepository,CustomFieldRepository), and the legacySubmission/Appointment/BlockedDaterepositories underincludes/repositories/. (PR #76) CONTRIBUTING.md— "Static analysis conventions" section. Documents the recurring@phpstan-ignore-next-line argument.typeannotations on$wpdb->prepare( "... {$where} ..." )(the WordPress stub'sliteral-stringrequirement collides with safe-by-construction dynamic queries that build IN-clauses viaarray_fill( '%d' )or usesanitize_sql_orderby()output) and the@phpstan-type/@phpstan-import-typerow alias workflow, so future contributors don't read either as ad-hoc suppressions. (PR #77)- "Move to form…" bulk action on the FFC Submissions admin list. When the list is filtered by a single form (
?post_type=ffc_form&page=ffc-submissions&filter_form_id=…), a new bulk option appears next to "Move to Trash". Selecting one or more submissions and choosing "Move to form…" opens a modal with a<select>of the other published forms; confirming rewrites those submissions'form_idin a single bulk UPDATE. The bulk option is hidden when the list is unfiltered or filtered by multiple forms — the source form must be unambiguous so the conflict-detection scope (per-form duplicate identifier) is well-defined. (PR #78) - Identifier-based conflict detection for the move action. A submission is treated as a duplicate of the target form when any of its populated identifiers (
cpf_hash,rf_hash,email_hash, or non-zerouser_id) matches an existing submission already present in the target — leveraging the existing(form_id, cpf_hash) / (form_id, rf_hash) / (email_hash, form_id)indexes for index-only lookups. Conflicting submissions are kept in the original form; non-conflicting ones move. The result is reported back via two admin notices: a green "X submissions moved to '…'" and a yellow "Y submissions were kept in the original form because an identifier already exists in '…'" with the original IDs spelled out so the operator can audit them. (PR #78) SubmissionRepository::moveBetweenForms( int $from, int $to, array $ids ): array{moved: list<int>, conflicts: list<int>}— the underlying repository method. Filters byform_id = $fromso accidental cross-form IDs are silently skipped, then probes the target form per row using only the columns the source row actually carries (so a submission without CPF doesn't fall through a CPF-only match path). The moves are batched into a single UPDATE; the cache and the count cache are invalidated when at least one row changes hands. (PR #78)SubmissionHandler::move_submissions_between_forms( int $from, int $to, array $ids )— handler wrapper that disablesActivityLogduring the bulk operation and emits a singlesubmission_movedaudit entry afterwards (withfrom_form_id,to_form_id,requested,moved_count,conflict_count,moved_ids[],conflict_ids[]), matching the disable-then-aggregate pattern already used bybulk_trash_submissions/bulk_restore_submissions/bulk_delete_submissions. (PR #78)- Modal asset bundle —
assets/js/ffc-admin-move-submissions.js(jQuery-based modal that intercepts the bulk form submit, presents the form picker, injects a hiddenmove_to_form_id, and resubmits) andassets/css/ffc-admin-move-submissions.css(centered dialog with a 4 px shadow over a translucent backdrop, matching the WP admin "thickbox" visual language without pulling thickbox itself for one screen). (PR #78) - Unit tests:
SubmissionRepositoryTestgrows from 91 → 94 tests (+3) covering the newmoveBetweenFormsmethod — empty IDs, source-equals-target short-circuit, and the moved-vs-conflict split with sequencedget_var()/query()mocks. (PR #78)
Changed
- PHPStan bumped from level 7 to level 8.
treatPhpDocTypesAsCertain: falseremoved; PHPDoc nullability is now honored. The strictest level was reached with zero baselined errors. (PR #76) phpstan-stubs.phpderives plugin constants fromffcertificate.php. Static parsing of the bootstrap file replaces the hand-maintaineddefine( 'FFC_VERSION', '4.12.26' )(which had drifted three minor versions behind the actual5.4.1); adding a new plugin constant no longer requires touching the stub file. (PR #76)- Centralized
$wpdb->prepare()null guards across the repository layer. Methods that compose SQL via$wpdb->prepare()and then run it via$wpdb->query()now reject thestring|nullreturn path explicitly;preg_replace()results that flow intostrlen()/Encryption::hash()/substr()are coerced with?? ''. (PR #76) - Migrated ~70 production call sites and ~80 test references away from
Utils::*deprecated shims to their target services —DocumentFormatter(CPF/RF/phone/email/auth-code helpers),AuthCodeService(random-string and globally-unique auth-code generation),SecurityService(captcha and security-field validation), andDataSanitizer(recursive sanitization and Brazilian-name normalization). Tests that previously alias-mocked\FreeFormCertificate\Core\Utilswere updated to alias-mock the target classes; tests that needed the realDocumentFormatter(becausePREFIX_*constants are referenced at the call site) now stubis_emailvia Brain\Monkey instead of replacing the class. (PR #76) - Repository properties with stricter types.
Admin::$csv_exportertypedCsvExporter(wasobject+// @phpstan-ignore);Settings::$tabstypedarray<string, \FreeFormCertificate\Settings\SettingsTab>(was untypedarray);UserDataRestController::get_sub()declares a conditional@phpstan-returnmatching key → sub-controller class, eliminating 12method.notFounderrors at the consumer call sites without a runtime cast. (PR #76) AudienceEnvironmentRepository::get_working_hours()return type. Wasarray<int, …>; the JSON column actually decodes toarray<string, …>keyed by weekday slug (mon,tue, …). Consumers that did$day_map[$day_key] ?? -1no longer need the conditional. (PR #76)ActivityLogaction vocabulary gainssubmission_moved. Already covered by the payload-based encryption gate that landed in 5.4.0 (the payload includes the moved-IDs list, which is metadata, not encrypted plaintext). (PR #78)AdminAssetsManager::is_submissions_list_page()— new helper that gates the modal asset bundle to the submissions list (excluding the?action=editsubpage already handled byis_submission_edit_page()). The modal is only enqueued when exactly onefilter_form_idis set, matching the bulk-action visibility rule. (PR #78)
Fixed
UserDashboard\UserManager::get_user_emails()could returnarray<int, string|null>. The decryption result is now narrowed viais_string()before theis_email()test, restoring the declaredarray<int, string>return shape. (PR #76)wp_date()/gmdate()calls passingint|falseinstead ofint|null. Affected screens: reregistration admin list (Periodcolumn), reregistration admin edit form (Start Date/End Dateinputs), reregistration form renderer (Deadlineline), certificate verification response renderer (Datefield), ficha generator. Eachstrtotime()result is now checked forfalsebefore being forwarded. (PR #76)- Frontend form processor —
process_submissionconstructedWP_Post|null->post_titlebecauseget_post()was assumed non-null. Now guarded by computing$form_post_titleonce and forwarding the safe string. (PR #76) DocumentFormatter—preg_replace()returnsstring|null. The CPF / RF / auth-code formatting and masking methods now coerce the return with?? ''(or?? $cpffor fall-through identity), restoring the declaredstringreturn type at level 8. (PR #76)MigrationStatusCalculator— three forwards toMigrationStrategyInterface(calculate_status,can_run,execute) acceptedarray<string, mixed>|nullfromMigrationRegistry::get_migration()but the strategy interface requiredarray<string, mixed>. Now coalesces to an empty array on miss. (PR #76)
Removed
phpstan-baseline.neon(231 suppressed errors). Errors are fixed at the source rather than masked. (PR #76)- Deprecated
Utils::*shim methods. Twenty methods (validate_cpf,validate_phone,validate_rf,format_cpf,format_rf,mask_cpf,mask_email,format_auth_code,format_document,clean_auth_code,clean_identifier,generate_random_string,generate_auth_code,generate_globally_unique_auth_code,generate_simple_captcha,verify_simple_captcha,validate_security_fields,recursive_sanitize,normalize_brazilian_name) plus thePHONE_REGEXconstant. Twoclass_exists( '\FreeFormCertificate\Core\Utils' )guards inFormRestController(which had outlived their purpose once the production calls were already routed throughDocumentFormatter) are also gone. (PR #76) Admin\CsvExporter::handle_export_request()— the@deprecated 5.0.0redirect-only stub. Together withAdmin::handle_csv_export_request(), the twoadd_action()calls that hooked it (admin_init+admin_post_ffc_export_csv), and the unit test that exercised the no-op. No live UI submits to the legacy POST endpoint. (PR #76)- Two "remove in next major version" legacy fallbacks —
ReprintDetector'sLIKE '%"cpf_rf":"…"%'JSON-data scan (the splitcpf_hash/rf_hashcolumns now cover the lookup) andVerificationHandler::build_appointment_result()'scpf_rflegacy-column path (the splitcpf/rfcolumns are populated). The corresponding tests were updated to use the split-column shape. (PR #76) - Three dead
onlyWrittenproperties —Admin::$form_editor,Admin::$settings_page,Frontend::$dynamic_fragmentstogether with their// @phpstan-ignore property.onlyWrittenannotations. The instances are now constructed as barenew X();because the target classes register their own WP hooks viaadd_action( ..., array( $this, ... ) )in their constructors — WordPress holds the references through the callback array, so the property was dead storage. (PR #77)
Security
- Permission gate for the move action. The new bulk action follows the existing
manage_optionsrequirement enforced bydisplay_submissions_pageand thebulk-submissionsnonce — no new capability surface. (PR #78)
Documentation
- PR descriptions cross-link to the audit. PR #76's description lists the obsolete code that was retired (Utils shims, two 6.0.0 fallbacks, the CsvExporter redirect stub) and the TO-DO items that fell out of the audit (repository typing — done in the same PR;
%iplaceholder false positives — documented as deliberate in PR #77;wp_insert_postaudit — non-finding, all four production call sites already pass$wp_error = true). CONTRIBUTING.mdstatic-analysis section (see Added). (PR #77)