Skip to content

v6.0.0

Choose a tag to compare

@rpgmem rpgmem released this 15 Jun 23:39
2cd30e4

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-normalized code, status ENUM draft|preliminary|active|closed, was_reopened flag for §5.1 freeze, and a public_columns_config JSON controlling the public shortcode's column visibility per notice), notice_adjutancy (N:N junction), candidate (standalone list with *_encrypted/*_hash pairs for CPF/RF/email + a NOT NULL pcd_hash HMAC over both PCD domains so "is PCD" is verifiable but not enumerable), classification (per (candidate, adjutancy, notice, list_type) row with rank/score and the empty→called→accepted→hired/not_shown state), and call (append-only convocation history with cancellation_reason/cancelled_at/cancelled_by stamping cancelled rows in place). All declared ENGINE=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 *Row aliases for its $wpdb->get_row/get_results shapes; consumers import via @phpstan-import-type so the level-8 type chain stays end-to-end. CRUD round-trips, UNIQUE-conflict handling on cpf_hash/rf_hash (returns 409 duplicate_cpf/duplicate_rf with existing_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, atomic START TRANSACTION/COMMIT/ROLLBACK around wipe + 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 UPDATE WHERE 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 iff empty + draft/preliminary; wp_user never touched), PcdHasher (HMAC-SHA256(wp_salt('auth')|module-suffix, ("1"|"0") || candidate_id), both domains stored), EmailDispatcher (wp_mail on 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 in wp_timezone()).
  • Recruitment module — state machines (PR #80). NoticeStateMachine enforces the §5.1 transition table (active→preliminary blocked once any call has ever been issued for the notice; closed→active flips was_reopened=1 one-way; preserved opened_at, overwritten closed_at). ClassificationStateMachine enforces the §5.2 table with the cross-aggregate reopen-freeze rule — once notice.was_reopened=1, every transition out of hired or not_shown is blocked for the rest of the notice's lifetime. Concurrency-safe via atomic conditional UPDATE; race losers receive 409 recruitment_race_lost. Promotion is the only path that writes to list_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/recruitment covering the §14 surface: notices (CRUD + status PATCH + public_columns_config validation rejecting rank=false or name=false + promote-preview with 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; 409 on duplicate CPF/RF or cross-vector user conflicts via UserCreator::get_or_create_user()), adjutancies (CRUD + 409 on delete with referencing rows), candidate→classification assignment, and GET /me/recruitment for 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_callback everywhere — admin routes gate on current_user_can('ffc_manage_recruitment'); the /me route on is_user_logged_in(). Error codes namespaced with recruitment_ prefix.
  • Recruitment module — auth + settings (PR #80). CapabilityManager::CONTEXT_RECRUITMENT constant added; activation grants ffc_manage_recruitment to administrator and registers the new ffc_recruitment_manager role (gets read + ffc_manage_recruitment). Single serialized option ffc_recruitment_settings (matches existing ffc_settings/ffc_geolocation_settings convention) 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 via public_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). notice required; adjutancy optional (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=). closed adds a "Edital encerrado." banner; the Chamados section shows date_to_assume. Per-notice column toggles via public_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, via DocumentFormatter). §8.4 error states wired (notice missing, notice unknown, adjutancy slug unknown, empty list). Server-side transient cache (TTL public_cache_seconds) + per-IP rate limit (cap public_rate_limit_per_minute, both 0 to 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 via candidate.user_id), per-notice grouping (drafts excluded), prévia/final banners depending on notice.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.php updates (PR #80). Drops the six recruitment tables (children-first: call, classification, notice_adjutancy, candidate, notice, adjutancy), deletes ffc_recruitment_settings, removes the ffc_recruitment_manager role, strips ffc_manage_recruitment from all users (mirrors the existing $ffcertificate_caps loop), and clears the ffc_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.status branch + 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-type for 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 legacy Submission / Appointment / BlockedDate repositories under includes/repositories/. (PR #76)
  • CONTRIBUTING.md — "Static analysis conventions" section. Documents the recurring @phpstan-ignore-next-line argument.type annotations on $wpdb->prepare( "... {$where} ..." ) (the WordPress stub's literal-string requirement collides with safe-by-construction dynamic queries that build IN-clauses via array_fill( '%d' ) or use sanitize_sql_orderby() output) and the @phpstan-type / @phpstan-import-type row 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_id in 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-zero user_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 by form_id = $from so 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 disables ActivityLog during the bulk operation and emits a single submission_moved audit entry afterwards (with from_form_id, to_form_id, requested, moved_count, conflict_count, moved_ids[], conflict_ids[]), matching the disable-then-aggregate pattern already used by bulk_trash_submissions / bulk_restore_submissions / bulk_delete_submissions. (PR #78)
  • Modal asset bundleassets/js/ffc-admin-move-submissions.js (jQuery-based modal that intercepts the bulk form submit, presents the form picker, injects a hidden move_to_form_id, and resubmits) and assets/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: SubmissionRepositoryTest grows from 91 → 94 tests (+3) covering the new moveBetweenForms method — empty IDs, source-equals-target short-circuit, and the moved-vs-conflict split with sequenced get_var() / query() mocks. (PR #78)

Changed

  • PHPStan bumped from level 7 to level 8. treatPhpDocTypesAsCertain: false removed; PHPDoc nullability is now honored. The strictest level was reached with zero baselined errors. (PR #76)
  • phpstan-stubs.php derives plugin constants from ffcertificate.php. Static parsing of the bootstrap file replaces the hand-maintained define( 'FFC_VERSION', '4.12.26' ) (which had drifted three minor versions behind the actual 5.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 the string|null return path explicitly; preg_replace() results that flow into strlen() / 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), and DataSanitizer (recursive sanitization and Brazilian-name normalization). Tests that previously alias-mocked \FreeFormCertificate\Core\Utils were updated to alias-mock the target classes; tests that needed the real DocumentFormatter (because PREFIX_* constants are referenced at the call site) now stub is_email via Brain\Monkey instead of replacing the class. (PR #76)
  • Repository properties with stricter types. Admin::$csv_exporter typed CsvExporter (was object + // @phpstan-ignore); Settings::$tabs typed array<string, \FreeFormCertificate\Settings\SettingsTab> (was untyped array); UserDataRestController::get_sub() declares a conditional @phpstan-return matching key → sub-controller class, eliminating 12 method.notFound errors at the consumer call sites without a runtime cast. (PR #76)
  • AudienceEnvironmentRepository::get_working_hours() return type. Was array<int, …>; the JSON column actually decodes to array<string, …> keyed by weekday slug (mon, tue, …). Consumers that did $day_map[$day_key] ?? -1 no longer need the conditional. (PR #76)
  • ActivityLog action vocabulary gains submission_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=edit subpage already handled by is_submission_edit_page()). The modal is only enqueued when exactly one filter_form_id is set, matching the bulk-action visibility rule. (PR #78)

Fixed

  • UserDashboard\UserManager::get_user_emails() could return array<int, string|null>. The decryption result is now narrowed via is_string() before the is_email() test, restoring the declared array<int, string> return shape. (PR #76)
  • wp_date() / gmdate() calls passing int|false instead of int|null. Affected screens: reregistration admin list (Period column), reregistration admin edit form (Start Date / End Date inputs), reregistration form renderer (Deadline line), certificate verification response renderer (Date field), ficha generator. Each strtotime() result is now checked for false before being forwarded. (PR #76)
  • Frontend form processorprocess_submission constructed WP_Post|null->post_title because get_post() was assumed non-null. Now guarded by computing $form_post_title once and forwarding the safe string. (PR #76)
  • DocumentFormatterpreg_replace() returns string|null. The CPF / RF / auth-code formatting and masking methods now coerce the return with ?? '' (or ?? $cpf for fall-through identity), restoring the declared string return type at level 8. (PR #76)
  • MigrationStatusCalculator — three forwards to MigrationStrategyInterface (calculate_status, can_run, execute) accepted array<string, mixed>|null from MigrationRegistry::get_migration() but the strategy interface required array<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 the PHONE_REGEX constant. Two class_exists( '\FreeFormCertificate\Core\Utils' ) guards in FormRestController (which had outlived their purpose once the production calls were already routed through DocumentFormatter) are also gone. (PR #76)
  • Admin\CsvExporter::handle_export_request() — the @deprecated 5.0.0 redirect-only stub. Together with Admin::handle_csv_export_request(), the two add_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 fallbacksReprintDetector's LIKE '%"cpf_rf":"…"%' JSON-data scan (the split cpf_hash / rf_hash columns now cover the lookup) and VerificationHandler::build_appointment_result()'s cpf_rf legacy-column path (the split cpf / rf columns are populated). The corresponding tests were updated to use the split-column shape. (PR #76)
  • Three dead onlyWritten propertiesAdmin::$form_editor, Admin::$settings_page, Frontend::$dynamic_fragments together with their // @phpstan-ignore property.onlyWritten annotations. The instances are now constructed as bare new X(); because the target classes register their own WP hooks via add_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_options requirement enforced by display_submissions_page and the bulk-submissions nonce — 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; %i placeholder false positives — documented as deliberate in PR #77; wp_insert_post audit — non-finding, all four production call sites already pass $wp_error = true).
  • CONTRIBUTING.md static-analysis section (see Added). (PR #77)