{ "path": "plugins/yaaru-core/includes/class-yaaru-rest.php", "size": 146660, "modified": "2026-10-04 12:04:58", "content": " 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_config'],\n\t\t\t'permission_callback' => '__return_true',\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/settings', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'update_settings'],\n\t\t\t'permission_callback' => fn() => Yaaru_Access::can_settings_context(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/effectifs', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_effectifs'],\n\t\t\t'permission_callback' => '__return_true',\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => [__CLASS__, 'list_children'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'create_child'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'check_public_nonce'],\n\t\t\t],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_child'],\n\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/status', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'update_status'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/request-changes', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'request_changes'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/parent-change-requests', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => [__CLASS__, 'list_parent_change_requests'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'create_parent_change_request'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t\t],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/parent-change-requests/(?P\\d+)/decision', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'decide_parent_change_request'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/smart-dossier', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'smart_dossier'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/smart-dossier/request', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'smart_dossier_request'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/versions', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'list_submission_versions'],\n\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/timeline', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_child_timeline'],\n\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/documents', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'update_document'],\n\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/notes', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'add_note'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/repertoire', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_repertoire'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/stats', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_stats'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\n\t\tregister_rest_route(self::NS, '/data-health', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_data_health'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/runtime/diagnostics', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_runtime_diagnostics'],\n\t\t\t'permission_callback' => fn() => current_user_can('manage_options'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/me/children', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_my_children'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/me/attendance/(?P\\d+)', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_my_attendance'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/me/profile', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_my_profile'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/me/profile/photo', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'update_my_profile_photo'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/me/password', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'update_my_password'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/educateur/classe/(?P[a-z0-9_-]+)', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_classe_roster'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_educator'),\n\t\t]);\n\n\n\n\n\t\tregister_rest_route(self::NS, '/me/draft', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => ['Yaaru_Drafts', 'current_for_user'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/me/draft/recoverable', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => ['Yaaru_Drafts', 'recoverable_for_user'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/drafts/(?P\\d+)/recover', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => ['Yaaru_Drafts', 'recover'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/drafts', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => ['Yaaru_Drafts', 'list_admin'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => ['Yaaru_Drafts', 'create'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'check_draft_nonce'],\n\t\t\t],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/drafts/(?P\\d+)', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => ['Yaaru_Drafts', 'get'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'can_access_draft'],\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => ['Yaaru_Drafts', 'update'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'check_draft_nonce'],\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'DELETE',\n\t\t\t\t'callback' => ['Yaaru_Drafts', 'delete'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'check_draft_nonce'],\n\t\t\t],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/drafts/(?P\\d+)/files', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => ['Yaaru_Drafts', 'upload'],\n\t\t\t'permission_callback' => [__CLASS__, 'check_draft_nonce'],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/files/(?P\\d+)', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => ['Yaaru_Files', 'serve'],\n\t\t\t// Le contrôle d'accès réel est déjà fait dans Yaaru_Files::serve()\n\t\t\t// (can_access_file_row()) ; il est répété ici, au niveau de\n\t\t\t// l'enregistrement de la route, en défense en profondeur — pour\n\t\t\t// qu'un futur refactor de serve() ne puisse pas rouvrir un accès\n\t\t\t// anonyme aux documents des enfants sans qu'aucun test REST ne le\n\t\t\t// détecte (la route ne serait alors plus jamais \"publique\" à tort).\n\t\t\t'permission_callback' => [__CLASS__, 'can_access_file'],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/retire', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'retire_child'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/documents/(?P[a-zA-Z0-9_-]+)/status', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'update_document_status'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/class-exceptions', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => [__CLASS__, 'list_class_exceptions'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'create_class_exception'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t\t],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/class-exceptions', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'list_all_class_exceptions'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/class-exceptions/(?P\\d+)/decision', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'decide_class_exception'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/pickup-requests', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => [__CLASS__, 'list_pickup_requests'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'create_pickup_request'],\n\t\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t\t],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/pickup-requests', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'list_all_pickup_requests'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/pickup-requests/(?P\\d+)/decision', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'decide_pickup_request'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\n\t\t// 3.27.0 — verrou d'urgence : retirer immédiatement une personne de la\n\t\t// liste des sorties, sans attendre une validation administrative. La\n\t\t// confirmation se fait par code envoyé au parent (ou mot de passe).\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/pickup-persons/(?P[^/]+)/suspension-code', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'send_pickup_suspension_code'],\n\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t]);\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/pickup-persons/(?P[^/]+)/suspend', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'suspend_pickup_person'],\n\t\t\t'permission_callback' => [__CLASS__, 'can_view_child'],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/attendance/(?P[a-z0-9_-]+)', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => [__CLASS__, 'get_attendance'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_educator') || current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'save_attendance'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_educator') || current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t]);\n\n\n\t\tregister_rest_route(self::NS, '/movements', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => [__CLASS__, 'list_movements'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'create_movement'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/children/(?P\\d+)/pickup-options', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'pickup_options'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/me/movements/(?P\\d+)', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_my_movements'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/me/timeline/(?P\\d+)', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_my_timeline'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/movements/(?P\\d+)/cancel', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'cancel_movement'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/me/notifications', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'get_my_notifications'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\t\tregister_rest_route(self::NS, '/me/notifications/(?P\\d+)/read', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'mark_my_notification_read'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\t\tregister_rest_route(self::NS, '/me/notifications/read-all', [\n\t\t\t'methods' => 'POST',\n\t\t\t'callback' => [__CLASS__, 'mark_my_notifications_read'],\n\t\t\t'permission_callback' => fn() => is_user_logged_in() && Yaaru_Access::can_use_parent_context(),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/payments', [\n\t\t\t'methods' => 'GET',\n\t\t\t'callback' => [__CLASS__, 'list_payments'],\n\t\t\t'permission_callback' => fn() => current_user_can('yaaru_finance') || current_user_can('yaaru_manage'),\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/announcements', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => [__CLASS__, 'list_announcements'],\n\t\t\t\t'permission_callback' => '__return_true',\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'create_announcement'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t]);\n\t\tregister_rest_route(self::NS, '/announcements/(?P\\d+)', [\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'update_announcement'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'DELETE',\n\t\t\t\t'callback' => [__CLASS__, 'delete_announcement'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t]);\n\n\t\tregister_rest_route(self::NS, '/events', [\n\t\t\t[\n\t\t\t\t'methods' => 'GET',\n\t\t\t\t'callback' => [__CLASS__, 'list_events'],\n\t\t\t\t'permission_callback' => '__return_true',\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'create_event'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t]);\n\t\tregister_rest_route(self::NS, '/events/(?P\\d+)', [\n\t\t\t[\n\t\t\t\t'methods' => 'POST',\n\t\t\t\t'callback' => [__CLASS__, 'update_event'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'methods' => 'DELETE',\n\t\t\t\t'callback' => [__CLASS__, 'delete_event'],\n\t\t\t\t'permission_callback' => fn() => current_user_can('yaaru_manage'),\n\t\t\t],\n\t\t]);\n\t}\n\n\t// ------------------------------------------------------------ Sécurité\n\n\tpublic static function check_public_nonce(WP_REST_Request $req) {\n\t\t$nonce = $req->get_header('X-WP-Nonce');\n\t\tif (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) return false;\n\t\t$data = $req->get_json_params() ?: [];\n\t\tif (!empty($data['honeypot'])) return false;\n\n\t\t// Une correction officiellement ouverte par l'école est un nouveau cycle\n\t\t// métier autorisé, pas un doublon. Le parent doit pouvoir la renvoyer\n\t\t// immédiatement après avoir corrigé les rubriques demandées, même si la\n\t\t// première soumission est encore dans la fenêtre anti-doublon.\n\t\t$draft_id = absint($data['draftId'] ?? 0);\n\t\tif ($draft_id > 0) {\n\t\t\t$draft = Yaaru_Drafts::row($draft_id);\n\t\t\t$child_id = $draft ? absint($draft['child_id'] ?? 0) : 0;\n\t\t\tif ($draft && $child_id > 0 && Yaaru_Drafts::can_access($draft, $req)) {\n\t\t\t\t$child = self::row($child_id);\n\t\t\t\t$change = Yaaru_Submissions::latest_request($child_id);\n\t\t\t\tif ($child && ($child['statut_dossier'] ?? '') === 'correction_demandee' && $change && ($change['status'] ?? '') === 'open') {\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t$email = sanitize_email($data['parents'][0]['email'] ?? '');\n\t\t$ip = sanitize_text_field($_SERVER['REMOTE_ADDR'] ?? 'unknown');\n\t\t$key = 'yaaru_submit_' . md5($ip . '|' . strtolower($email));\n\t\tif (get_transient($key)) return new WP_Error('rate_limited', 'Une demande similaire vient déjà d’être envoyée. Réessayez plus tard.', ['status'=>429]);\n\t\treturn true;\n\t}\n\n\n\tpublic static function can_access_draft(WP_REST_Request $req) {\n\t\t$id = (int) $req['id'];\n\t\tif ($id <= 0) return false;\n\t\t$row = Yaaru_Drafts::row($id);\n\t\tif (!$row) return true; // allow callback to return a clean 404\n\t\treturn Yaaru_Drafts::can_access($row, $req);\n\t}\n\n\tpublic static function check_draft_nonce(WP_REST_Request $req) {\n\t\t$nonce = $req->get_header('X-WP-Nonce');\n\t\tif (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) return false;\n\t\treturn true;\n\t}\n\n\tpublic static function can_view_child(WP_REST_Request $req) {\n\t\tif (current_user_can('manage_options') || current_user_can('yaaru_manage')) return true;\n\t\t$id = (int) $req['id'];\n\t\tif (Yaaru_Access::can_use_parent_context() && self::is_guardian_of(get_current_user_id(), $id)) return true;\n\t\tif (current_user_can('yaaru_educator')) {\n\t\t\t$child = self::row($id);\n\t\t\tif ($child && self::educator_teaches($child['classe_id'])) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t/** permission_callback de GET /files/{id} — voir le commentaire à l'enregistrement de la route. */\n\tpublic static function can_access_file(WP_REST_Request $req) {\n\t\tglobal $wpdb;\n\t\t$row = $wpdb->get_row($wpdb->prepare(\"SELECT * FROM {$wpdb->prefix}yaaru_files WHERE id=%d\", (int) $req['id']), ARRAY_A);\n\t\tif (!$row) return true; // laisse le callback renvoyer un 404 propre plutôt qu'un 403 trompeur\n\n\t\t// 3.18.1 — IMPORTANT : tester d'abord le jeton média navigateur.\n\t\t// Les requêtes d'une balise vers wp-json n'envoient pas X-WP-Nonce ;\n\t\t// WordPress traite donc la requête REST comme anonyme malgré le cookie de\n\t\t// session. Le jeton HMAC court permet l'affichage sans rendre le fichier public.\n\t\tif (class_exists('Yaaru_Files') && method_exists('Yaaru_Files', 'can_access_signed_browser_request')\n\t\t\t&& Yaaru_Files::can_access_signed_browser_request($row, $req)) return true;\n\n\t\tif (self::can_access_file_row($row)) return true;\n\t\tif (class_exists('Yaaru_Drafts') && Yaaru_Drafts::can_access_file_request($row, $req)) return true;\n\t\treturn false;\n\t}\n\n\tprivate static function is_guardian_of($user_id, $child_id) {\n\t\tglobal $wpdb;\n\t\t$t = $wpdb->prefix . 'yaaru_guardians';\n\t\treturn (bool) $wpdb->get_var($wpdb->prepare(\"SELECT id FROM {$t} WHERE user_id=%d AND child_id=%d\", $user_id, $child_id));\n\t}\n\n\t/** Démonstration : l'éducateur est rattaché à une classe via ses meta utilisateur (yaaru_classe_id). */\n\tprivate static function educator_teaches($classe_id) {\n\t\t$assigned = get_user_meta(get_current_user_id(), 'yaaru_classe_id', true);\n\t\treturn $assigned && $assigned === $classe_id;\n\t}\n\n\n\t/**\n\t * 3.25.0 — Historique consolidé de l'enfant.\n\t *\n\t * On agrège les événements métier déjà présents (dossier, documents,\n\t * paiements, présences, entrées/sorties, personnes autorisées, classe,\n\t * départ) sans créer une seconde source de vérité. Le flux Direction peut\n\t * afficher davantage de détails internes ; le flux Parent reste volontairement\n\t * limité aux événements utiles à la famille.\n\t */\n\tprivate static function child_timeline(int $child_id, bool $include_internal = false, int $limit = 80): array {\n\t\tglobal $wpdb;\n\t\t$row = self::row($child_id);\n\t\tif (!$row) return [];\n\t\t$limit = max(10, min(200, $limit));\n\t\t$events = [];\n\t\t$push = static function(array &$events, string $date, string $type, string $title, string $detail = '', array $meta = []) {\n\t\t\tif ($date === '') return;\n\t\t\t$events[] = array_merge([\n\t\t\t\t'date' => $date,\n\t\t\t\t'type' => $type,\n\t\t\t\t'title' => $title,\n\t\t\t\t'detail' => $detail,\n\t\t\t], $meta);\n\t\t};\n\n\t\t// Point de départ du dossier, toujours disponible même sur les anciennes données.\n\t\t$push($events, (string)$row['created_at'], 'dossier', 'Dossier créé', 'Référence '.$row['numero_dossier']);\n\n\t\t// Versions d'inscription et demandes de correction.\n\t\t$versions = $wpdb->get_results($wpdb->prepare(\n\t\t\t\"SELECT version,event,created_at FROM {$wpdb->prefix}yaaru_submission_versions WHERE child_id=%d ORDER BY id DESC LIMIT 60\",\n\t\t\t$child_id\n\t\t), ARRAY_A) ?: [];\n\t\tforeach ($versions as $v) {\n\t\t\t$event = (string)$v['event'];\n\t\t\t$title = $event === 'resubmitted' ? 'Corrections envoyées' : ($event === 'changes_requested' ? 'Correction demandée' : 'Dossier envoyé');\n\t\t\t$detail = 'Version '.(int)$v['version'];\n\t\t\t$push($events, (string)$v['created_at'], 'dossier', $title, $detail);\n\t\t}\n\n\t\t// Entrées / sorties : données figées au moment du pointage.\n\t\t$moves = $wpdb->get_results($wpdb->prepare(\n\t\t\t\"SELECT event_type,occurred_at,pickup_name,pickup_relation,verification_method FROM {$wpdb->prefix}yaaru_movements WHERE child_id=%d ORDER BY occurred_at DESC LIMIT 80\",\n\t\t\t$child_id\n\t\t), ARRAY_A) ?: [];\n\t\tforeach ($moves as $m) {\n\t\t\tif ($m['event_type'] === 'arrival') {\n\t\t\t\t$push($events, (string)$m['occurred_at'], 'movement', 'Arrivée à l’école', 'Présence enregistrée par l’école');\n\t\t\t} else {\n\t\t\t\t$detail = $m['pickup_name'] ? 'Avec '.$m['pickup_name'].($m['pickup_relation'] ? ' ('.$m['pickup_relation'].')' : '') : 'Sortie enregistrée par l’école';\n\t\t\t\t$push($events, (string)$m['occurred_at'], 'movement', 'Sortie de l’école', $detail);\n\t\t\t}\n\t\t}\n\n\t\t// Présences : utile pour la continuité scolaire, sans exposer les détails d'autres enfants.\n\t\t$attendance = $wpdb->get_results($wpdb->prepare(\n\t\t\t\"SELECT date_presence,statut,updated_at FROM {$wpdb->prefix}yaaru_attendance WHERE child_id=%d ORDER BY date_presence DESC LIMIT 45\",\n\t\t\t$child_id\n\t\t), ARRAY_A) ?: [];\n\t\t$attendance_labels = ['present'=>'Présence enregistrée','absent'=>'Absence enregistrée','retard'=>'Retard enregistré','excuse'=>'Absence excusée'];\n\t\tforeach ($attendance as $a) {\n\t\t\t$status = sanitize_key($a['statut']);\n\t\t\t$push($events, (string)($a['updated_at'] ?: ($a['date_presence'].' 12:00:00')), 'attendance', $attendance_labels[$status] ?? 'Suivi de présence', 'Journée du '.$a['date_presence']);\n\t\t}\n\n\t\t// Paiements : création d'échéance + confirmation. Les montants restent ceux de la source comptable.\n\t\t$payments = $wpdb->get_results($wpdb->prepare(\n\t\t\t\"SELECT id,libelle,montant,statut,created_at,paid_at,moyen,reference FROM {$wpdb->prefix}yaaru_payments WHERE child_id=%d ORDER BY id DESC LIMIT 80\",\n\t\t\t$child_id\n\t\t), ARRAY_A) ?: [];\n\t\tforeach ($payments as $pay) {\n\t\t\t$amount = number_format((int)$pay['montant'], 0, ',', ' ').' FCFA';\n\t\t\t$push($events, (string)$pay['created_at'], 'payment', 'Échéance enregistrée', trim($pay['libelle'].' · '.$amount));\n\t\t\tif (!empty($pay['paid_at']) || in_array($pay['statut'], ['paye','payee','valide','validee'], true)) {\n\t\t\t\t$detail = trim($pay['libelle'].' · '.$amount.(!empty($pay['moyen']) ? ' · '.$pay['moyen'] : ''));\n\t\t\t\t$push($events, (string)($pay['paid_at'] ?: $pay['created_at']), 'payment', 'Paiement confirmé', $detail);\n\t\t\t}\n\t\t}\n\n\t\t// Événements audités qui ont une vraie valeur métier pour l'historique.\n\t\t$allowed = [\n\t\t\t'status_changed','document_uploaded','document_status_changed',\n\t\t\t'class_exception_requested','class_exception_decided',\n\t\t\t'pickup_request_created','pickup_request_decided',\n\t\t\t'child_movement_cancelled','child_retired'\n\t\t];\n\t\tif ($include_internal) $allowed[] = 'note_added';\n\t\t$ph = implode(',', array_fill(0, count($allowed), '%s'));\n\t\t$args = array_merge([$child_id], $allowed);\n\t\t$sql = $wpdb->prepare(\"SELECT user_id,action,details,created_at FROM {$wpdb->prefix}yaaru_activity_log WHERE child_id=%d AND action IN ({$ph}) ORDER BY id DESC LIMIT 120\", $args);\n\t\t$logs = $wpdb->get_results($sql, ARRAY_A) ?: [];\n\t\tforeach ($logs as $log) {\n\t\t\t$d = json_decode((string)$log['details'], true) ?: [];\n\t\t\t$action = (string)$log['action'];\n\t\t\t$title = ''; $detail = ''; $type = 'dossier';\n\t\t\tswitch ($action) {\n\t\t\t\tcase 'status_changed':\n\t\t\t\t\t$title = 'Statut du dossier mis à jour'; $detail = !empty($d['to']) ? str_replace('_',' ',(string)$d['to']) : ''; break;\n\t\t\t\tcase 'document_uploaded':\n\t\t\t\t\t$title = 'Document ajouté'; $detail = (string)($d['name'] ?? 'Pièce du dossier'); $type = 'document'; break;\n\t\t\t\tcase 'document_status_changed':\n\t\t\t\t\t$title = 'Document vérifié'; $detail = trim((string)($d['doc'] ?? '').(!empty($d['statut']) ? ' · '.str_replace('_',' ',(string)$d['statut']) : '')); $type = 'document'; break;\n\t\t\t\tcase 'class_exception_requested':\n\t\t\t\t\t$title = 'Demande de classe enregistrée'; $detail = 'Écart par rapport à la classe recommandée'; $type = 'class'; break;\n\t\t\t\tcase 'class_exception_decided':\n\t\t\t\t\t$title = 'Décision sur la classe'; $detail = !empty($d['statut']) ? str_replace('_',' ',(string)$d['statut']) : ''; $type = 'class'; break;\n\t\t\t\tcase 'pickup_request_created':\n\t\t\t\t\t$title = 'Changement de personne autorisée demandé'; $detail = !empty($d['type']) ? ucfirst((string)$d['type']) : ''; $type = 'pickup'; break;\n\t\t\t\tcase 'pickup_request_decided':\n\t\t\t\t\t$title = 'Personne autorisée : décision enregistrée'; $detail = !empty($d['statut']) ? str_replace('_',' ',(string)$d['statut']) : ''; $type = 'pickup'; break;\n\t\t\t\tcase 'child_movement_cancelled':\n\t\t\t\t\t$title = 'Pointage corrigé'; $detail = $include_internal ? (string)($d['reason'] ?? 'Correction enregistrée') : 'Une correction de pointage a été effectuée par l’école.'; $type = 'movement'; break;\n\t\t\t\tcase 'child_retired':\n\t\t\t\t\t$title = 'Départ définitif enregistré'; $detail = $include_internal ? (string)($d['motif'] ?? '') : 'Scolarité clôturée dans l’établissement.'; $type = 'departure'; break;\n\t\t\t\tcase 'note_added':\n\t\t\t\t\t$title = 'Note interne ajoutée'; $detail = 'Visible uniquement par l’équipe autorisée.'; $type = 'internal'; break;\n\t\t\t}\n\t\t\tif ($title === '') continue;\n\t\t\t$meta = [];\n\t\t\tif ($include_internal && !empty($log['user_id'])) {\n\t\t\t\t$u = get_userdata((int)$log['user_id']);\n\t\t\t\tif ($u) $meta['actor'] = $u->display_name;\n\t\t\t}\n\t\t\t$push($events, (string)$log['created_at'], $type, $title, $detail, $meta);\n\t\t}\n\n\t\t// Départ historique même si le log d'audit n'existe pas sur un ancien dossier.\n\t\tif (!empty($row['date_depart'])) {\n\t\t\t$has_departure = false;\n\t\t\tforeach ($events as $e) if ($e['type'] === 'departure') { $has_departure = true; break; }\n\t\t\tif (!$has_departure) $push($events, (string)$row['date_depart'].' 12:00:00', 'departure', 'Départ définitif enregistré', $include_internal ? (string)($row['motif_depart'] ?? '') : 'Scolarité clôturée dans l’établissement.');\n\t\t}\n\n\t\tusort($events, static fn($a,$b) => strcmp((string)$b['date'], (string)$a['date']));\n\t\treturn array_slice($events, 0, $limit);\n\t}\n\n\tpublic static function get_child_timeline(WP_REST_Request $req) {\n\t\t$id = (int)$req['id'];\n\t\tif (!self::row($id)) return new WP_Error('not_found', 'Enfant introuvable.', ['status'=>404]);\n\t\t$limit = (int)($req->get_param('limit') ?: 80);\n\t\treturn rest_ensure_response(self::child_timeline($id, current_user_can('yaaru_manage'), $limit));\n\t}\n\n\tpublic static function get_my_timeline(WP_REST_Request $req) {\n\t\t$id = (int)$req['child_id'];\n\t\tif (!self::is_guardian_of(get_current_user_id(), $id)) return new WP_Error('forbidden', 'Accès refusé.', ['status'=>403]);\n\t\t$limit = (int)($req->get_param('limit') ?: 60);\n\t\treturn rest_ensure_response(self::child_timeline($id, false, $limit));\n\t}\n\n\t// -------------------------------------------------------------- Config\n\n\tpublic static function get_config() {\n\t\treturn Yaaru_Config::get();\n\t}\n\n\t/**\n\t * Écran « Paramètres Yaaru » — jusqu'ici un simple aperçu en lecture\n\t * seule (\"Édition à venir\"), pour la partie établissement/affichage de\n\t * la configuration (celle stockée en option WordPress). Les capacités\n\t * par classe, tarifs et activités restent en lecture seule dans cette\n\t * version : elles vivent dans Yaaru_Config::get() en dur, pas en\n\t * option — les rendre éditables changerait le schéma de configuration\n\t * lui-même, hors périmètre de cette passe (documenté au client).\n\t */\n\tpublic static function update_settings(WP_REST_Request $req) {\n\t\t$data = $req->get_json_params() ?: [];\n\t\t$opts = get_option(Yaaru_Core::OPT, []);\n\t\t$opts = is_array($opts) ? $opts : [];\n\n\t\tif (isset($data['school_year'])) {\n\t\t\t$sy = sanitize_text_field($data['school_year']);\n\t\t\tif (!preg_match('/^\\d{4}-\\d{4}$/', $sy)) return new WP_Error('invalid', 'Année scolaire invalide (format attendu : 2026-2027).', ['status' => 400]);\n\t\t\t$opts['school_year'] = $sy;\n\t\t}\n\t\tif (isset($data['reference_date'])) {\n\t\t\t$rd = sanitize_text_field($data['reference_date']);\n\t\t\tif (!preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $rd)) return new WP_Error('invalid', 'Date de référence pédagogique invalide.', ['status' => 400]);\n\t\t\t$opts['reference_date'] = $rd;\n\t\t}\n\t\tif (isset($data['phone'])) $opts['phone'] = sanitize_text_field($data['phone']);\n\t\tif (isset($data['whatsapp'])) $opts['whatsapp'] = preg_replace('/\\D/', '', sanitize_text_field($data['whatsapp']));\n\t\tif (isset($data['email'])) {\n\t\t\t$email = sanitize_email($data['email']);\n\t\t\tif ($data['email'] && !$email) return new WP_Error('invalid', 'Adresse e-mail invalide.', ['status' => 400]);\n\t\t\t$opts['email'] = $email;\n\t\t}\n\t\tif (isset($data['tarifs_display_mode'])) {\n\t\t\t$mode = sanitize_key($data['tarifs_display_mode']);\n\t\t\tif (!in_array($mode, ['visible', 'a_partir', 'masque'], true)) return new WP_Error('invalid', 'Mode d’affichage des tarifs invalide.', ['status' => 400]);\n\t\t\t$opts['tarifs_display_mode'] = $mode;\n\t\t}\n\t\tif (isset($data['public_rate_limit_minutes'])) {\n\t\t\t$min = (int) $data['public_rate_limit_minutes'];\n\t\t\tif ($min < 1 || $min > 1440) return new WP_Error('invalid', 'Le délai anti-doublon doit être entre 1 et 1440 minutes.', ['status' => 400]);\n\t\t\t$opts['public_rate_limit_minutes'] = $min;\n\t\t}\n\n\t\tupdate_option(Yaaru_Core::OPT, $opts);\n\t\tYaaru_Audit::log('settings_updated', null, ['fields' => array_keys($data)]);\n\t\t// Yaaru_Config::get() garde un cache statique par requête PHP — cette\n\t\t// requête se termine juste après, donc la prochaine lecture (page\n\t\t// suivante) relira bien l'option fraîchement enregistrée.\n\t\treturn Yaaru_Config::get();\n\t}\n\n\tpublic static function get_effectifs() {\n\t\t$out = [];\n\t\tforeach (Yaaru_Config::get()['classes'] as $c) {\n\t\t\t$out[] = [\n\t\t\t\t'classe' => $c,\n\t\t\t\t'inscrits' => Yaaru_Config::places_occupees($c['id']),\n\t\t\t\t'restantes' => Yaaru_Config::places_restantes($c['id']),\n\t\t\t\t'libelle' => Yaaru_Config::libelle_places($c['id']),\n\t\t\t];\n\t\t}\n\t\treturn $out;\n\t}\n\n\t// ------------------------------------------------------------ Children\n\n\tprivate static function row($id) {\n\t\tglobal $wpdb;\n\t\t$t = $wpdb->prefix . 'yaaru_children';\n\t\t$row = $wpdb->get_row($wpdb->prepare(\"SELECT * FROM {$t} WHERE id=%d\", $id), ARRAY_A);\n\t\treturn $row ?: null;\n\t}\n\n\t/** Reconstitue la forme JSON attendue par le front-end à partir des colonnes + du payload. */\n\tprivate static function to_json($row) {\n\t\t$payload = json_decode($row['payload'], true) ?: [];\n\n\t\t// 3.17.0 — les documents sont identifiés par un kind stable et non plus\n\t\t// par comparaison exacte d'un libellé (apostrophe droite/typographique).\n\t\tif (!empty($payload['documents']) && is_array($payload['documents'])) {\n\t\t\tforeach ($payload['documents'] as &$document) {\n\t\t\t\tif (!is_array($document)) continue;\n\t\t\t\t$document['nom'] = Yaaru_Config::canonical_document_label($document['nom'] ?? '');\n\t\t\t\t$document['kind'] = Yaaru_Config::document_kind($document['nom']);\n\t\t\t\t$document['isPhoto'] = $document['kind'] === 'photo_enfant';\n\t\t\t}\n\t\t\tunset($document);\n\t\t}\n\n\t\t$payload = Yaaru_Files::hydrate($payload);\n\n\t\t// Résolution robuste de la photo enfant :\n\t\t// 1) photo explicite dans le payload,\n\t\t// 2) document marqué comme photo enfant,\n\t\t// 3) slot stable `photo-enfant`,\n\t\t// 4) fallback legacy sur la meilleure image enfant connue.\n\t\tif (empty($payload['photo']) && !empty($payload['documents']) && is_array($payload['documents'])) {\n\t\t\tforeach ($payload['documents'] as $document) {\n\t\t\t\tif (!is_array($document)) continue;\n\t\t\t\t$is_photo = !empty($document['isPhoto']) || (($document['kind'] ?? '') === 'photo_enfant');\n\t\t\t\tif (!$is_photo) continue;\n\t\t\t\t$document_url = (string)($document['fichier'] ?? $document['fileUrl'] ?? $document['previewUrl'] ?? $document['url'] ?? '');\n\t\t\t\tif ($document_url !== '') {\n\t\t\t\t\t$payload['photo'] = $document_url;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (empty($payload['photo'])) {\n\t\t\t$photo_row = Yaaru_Files::latest_child_file_by_slot((int)$row['id'], Yaaru_Config::SLOT_PHOTO_ENFANT);\n\t\t\tif ($photo_row) $payload['photo'] = $photo_row['url'];\n\t\t}\n\t\tif (empty($payload['photo'])) {\n\t\t\t$photo_row = Yaaru_Files::latest_child_photo_guess((int)$row['id']);\n\t\t\tif ($photo_row) $payload['photo'] = $photo_row['url'];\n\t\t}\n\t\tglobal $wpdb;\n\t\t$notes_t = $wpdb->prefix . 'yaaru_notes';\n\t\t$notes = $wpdb->get_results($wpdb->prepare(\"SELECT texte, created_at FROM {$notes_t} WHERE child_id=%d ORDER BY id DESC\", $row['id']), ARRAY_A);\n\n\t\t$classe = Yaaru_Config::classe_by_id($row['classe_id']);\n\t\t$payments = $wpdb->get_results($wpdb->prepare(\"SELECT id, libelle, montant, echeance AS date, statut, moyen, reference, note, recorded_by AS recordedBy, wc_order_id AS wcOrderId, transaction_id AS transactionId, paid_at AS paidAt, source_type AS sourceType, source_ref AS sourceRef FROM {$wpdb->prefix}yaaru_payments WHERE child_id=%d ORDER BY id ASC\", $row['id']), ARRAY_A);\n\t\t$change_request = Yaaru_Submissions::latest_request((int)$row['id']);\n\t\t$versions = Yaaru_Submissions::list_versions((int)$row['id']);\n\n\t\t$result = array_merge($payload, [\n\t\t\t'id' => (string) $row['id'],\n\t\t\t'numeroDossier' => $row['numero_dossier'],\n\t\t\t'prenom' => $row['prenom'],\n\t\t\t'nom' => $row['nom'],\n\t\t\t'sexe' => $row['sexe'],\n\t\t\t'dateNaissance' => $row['date_naissance'],\n\t\t\t'classeId' => $row['classe_id'],\n\t\t\t'classeNom' => $classe['nom'] ?? $row['classe_id'],\n\t\t\t'niveau' => $classe['niveau'] ?? null,\n\t\t\t'statutDossier' => $row['statut_dossier'],\n\t\t\t'statutPlace' => $row['statut_place'],\n\t\t\t'motifDepart' => $row['motif_depart'] ?? null,\n\t\t\t'dateDepart' => $row['date_depart'] ?? null,\n\t\t\t'dateDossier' => substr($row['created_at'], 0, 10),\n\t\t\t'notesInternes' => array_map(fn($n) => ['date' => $n['created_at'], 'texte' => $n['texte']], $notes),\n\t\t\t'paiements' => $payments ?: ($payload['paiements'] ?? []),\n 'financeProfile' => class_exists('Yaaru_Payments') ? Yaaru_Payments::finance_profile((int)$row['id']) : ['regime'=>'standard','reduction_percent'=>0,'fully_exempt'=>false],\n\t\t\t'workflow' => [\n\t\t\t\t'locked' => !in_array($row['statut_dossier'], ['correction_demandee'], true),\n\t\t\t\t'changeRequest' => $change_request,\n\t\t\t\t'versionCount' => count($versions),\n\t\t\t],\n\t\t\t'downloads' => Yaaru_Submissions::download_urls((int)$row['id']),\n\t\t]);\n\t\t$result = self::filter_sensitive($result, $row);\n\t\treturn self::filter_financial($result);\n\t}\n\n\t/**\n\t * Retire la clé `sante` (allergies, traitement, médecin…) de la réponse\n\t * pour tout appelant qui n'a pas de raison légitime de la voir : un\n\t * compte `yaaru_manage` seul (secrétariat) ne l'a plus automatiquement,\n\t * contrairement à avant — il faut la capacité `yaaru_sensitive`\n\t * (direction). Le parent de l'enfant et l'éducateur de sa classe la\n\t * conservent : ils en ont besoin pour la sécurité de l'enfant au\n\t * quotidien, et c'est l'un des deux qui l'a renseignée à l'inscription.\n\t */\n\tprivate static function filter_sensitive(array $result, array $row): array {\n\t\tif (!array_key_exists('sante', $result)) return $result;\n\t\t$allowed = current_user_can('yaaru_sensitive')\n\t\t\t|| (Yaaru_Access::can_use_parent_context() && self::is_guardian_of(get_current_user_id(), (int) $row['id']))\n\t\t\t|| (current_user_can('yaaru_educator') && self::educator_teaches($row['classe_id']));\n\t\tif (!$allowed) unset($result['sante']);\n\t\treturn $result;\n\t}\n\n\t/** Les montants et régimes financiers ne quittent jamais l'API vers un profil non autorisé. */\n\tprivate static function filter_financial(array $result): array {\n\t\tif (Yaaru_Access::can_finance_context()) return $result;\n\t\tunset($result['paiements'], $result['financeProfile']);\n\t\treturn $result;\n\t}\n\n\tpublic static function list_children(WP_REST_Request $req) {\n\t\tglobal $wpdb;\n\t\t$t = $wpdb->prefix . 'yaaru_children';\n\t\t$where = ['1=1'];\n\t\t$args = [];\n\n\t\tif ($classe = $req->get_param('classeId')) { $where[] = 'classe_id = %s'; $args[] = $classe; }\n\t\tif ($statut = $req->get_param('statutDossier')) { $where[] = 'statut_dossier = %s'; $args[] = $statut; }\n\t\tif ($sexe = $req->get_param('sexe')) { $where[] = 'sexe = %s'; $args[] = $sexe; }\n\t\tif ($q = $req->get_param('q')) {\n\t\t\t$where[] = '(prenom LIKE %s OR nom LIKE %s OR numero_dossier LIKE %s OR payload LIKE %s)';\n\t\t\t$like = '%' . $wpdb->esc_like($q) . '%';\n\t\t\tarray_push($args, $like, $like, $like, $like);\n\t\t}\n\n\t\t$sql = \"SELECT * FROM {$t} WHERE \" . implode(' AND ', $where) . ' ORDER BY id DESC';\n\t\t$rows = $args ? $wpdb->get_results($wpdb->prepare($sql, $args), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A);\n\t\treturn array_map([__CLASS__, 'to_json'], $rows ?: []);\n\t}\n\n\tpublic static function get_child(WP_REST_Request $req) {\n\t\t$row = self::row((int) $req['id']);\n\t\tif (!$row) return new WP_Error('not_found', 'Enfant introuvable.', ['status' => 404]);\n\t\treturn self::to_json($row);\n\t}\n\n\t/**\n\t * Création d'un dossier depuis le parcours d'inscription public.\n\t * Toutes les données sensibles (classe, numéro de dossier) sont\n\t * recalculées ici — jamais acceptées depuis le corps de la requête.\n\t */\n\tpublic static function create_child(WP_REST_Request $req) {\n\t\tglobal $wpdb;\n\t\t$data = $req->get_json_params();\n\t\tif (!$data) return new WP_Error('invalid', 'Données manquantes.', ['status' => 400]);\n\n\t\t$draft_id = (int)($data['draftId'] ?? 0);\n\t\t$draft_token = sanitize_text_field($data['draftToken'] ?? '');\n\t\tif ($draft_id && !Yaaru_Drafts::verify_submission($draft_id, $draft_token)) {\n\t\t\treturn new WP_Error('invalid_draft', 'Le brouillon ne peut pas être utilisé pour cet envoi.', ['status'=>403]);\n\t\t}\n\t\t$draft_row = $draft_id ? Yaaru_Drafts::row($draft_id) : null;\n\t\t$existing_child_id = $draft_row ? (int)($draft_row['child_id'] ?? 0) : 0;\n\n\t\t$validation = self::validate_application($data);\n\t\tif (is_wp_error($validation)) return $validation;\n\n\t\t$prenom = sanitize_text_field($data['prenom']);\n\t\t$nom = sanitize_text_field($data['nom']);\n\t\t$sexe = $data['sexe'];\n\t\t$date_naissance = $data['dateNaissance'];\n\t\t$classe_recommandee = Yaaru_Config::calculer_classe($date_naissance);\n\t\tif (!empty($classe_recommandee['tropAge'])) return new WP_Error('invalid_age', $classe_recommandee['nom'], ['status'=>400]);\n\t\t$classe = $classe_recommandee;\n\t\t$classe_choisie_id = sanitize_key($data['classeChoisieId'] ?? '');\n\t\t$classe_choisie_commentaire = sanitize_textarea_field($data['classeChoisieCommentaire'] ?? '');\n\t\tif ($classe_choisie_id) {\n\t\t\t$requested_class = Yaaru_Config::classe_by_id($classe_choisie_id);\n\t\t\tif (!$requested_class) return new WP_Error('invalid_class_choice', 'La classe choisie n’est pas disponible.', ['status'=>400]);\n\t\t\t$classe = $requested_class;\n\t\t}\n\t\t$existing_child = $existing_child_id ? self::row($existing_child_id) : null;\n\n\t\t// 3.10.6 — une famille peut commencer par le formulaire Uniformes avant\n\t\t// son inscription complète. Après authentification, ce formulaire crée\n\t\t// au besoin une fiche enfant provisoire. Quand l'inscription complète\n\t\t// arrive ensuite, on réutilise cette fiche au lieu de créer un doublon.\n\t\tif (!$existing_child && is_user_logged_in()) {\n\t\t\t$g = $wpdb->prefix . 'yaaru_guardians'; $c = $wpdb->prefix . 'yaaru_children';\n\t\t\t$provisional = $wpdb->get_row($wpdb->prepare(\n\t\t\t\t\"SELECT c.* FROM {$c} c INNER JOIN {$g} g ON g.child_id=c.id WHERE g.user_id=%d AND c.statut_dossier='preinscription_uniforme' AND LOWER(c.prenom)=LOWER(%s) AND LOWER(c.nom)=LOWER(%s) AND c.date_naissance=%s ORDER BY c.id DESC LIMIT 1\",\n\t\t\t\tget_current_user_id(), $prenom, $nom, $date_naissance\n\t\t\t), ARRAY_A);\n\t\t\tif ($provisional) { $existing_child = $provisional; $existing_child_id = (int)$provisional['id']; }\n\t\t}\n\t\t$converting_provisional = $existing_child && $existing_child['statut_dossier'] === 'preinscription_uniforme';\n\t\tif ($existing_child && !$converting_provisional && $existing_child['statut_dossier'] !== 'correction_demandee') {\n\t\t\treturn new WP_Error('locked_submission', 'Ce dossier a déjà été transmis et n’est pas ouvert aux modifications.', ['status'=>409]);\n\t\t}\n\t\t$numero = $converting_provisional ? Yaaru_Config::generer_numero_dossier() : ($existing_child ? $existing_child['numero_dossier'] : Yaaru_Config::generer_numero_dossier());\n\n\t\t$incoming_file_ids = self::collect_file_ids($data);\n\t\tif ($incoming_file_ids && !$draft_id) {\n\t\t\treturn new WP_Error('invalid_file_reference', 'Référence de fichier non autorisée sans brouillon serveur.', ['status'=>400]);\n\t\t}\n\t\tif ($incoming_file_ids) {\n\t\t\t$files_ok = Yaaru_Drafts::files_belong_to_draft($incoming_file_ids, $draft_id);\n\t\t\tif (!$files_ok && $existing_child_id) {\n\t\t\t\t$ids = array_unique(array_filter(array_map('intval', $incoming_file_ids)));\n\t\t\t\t$in = implode(',', $ids);\n\t\t\t\t$count = $ids ? (int)$wpdb->get_var(\"SELECT COUNT(*) FROM {$wpdb->prefix}yaaru_files WHERE id IN ({$in}) AND (draft_id=\".(int)$draft_id.\" OR child_id=\".(int)$existing_child_id.\")\") : 0;\n\t\t\t\t$files_ok = $count === count($ids);\n\t\t\t}\n\t\t\tif (!$files_ok) return new WP_Error('invalid_file_reference', 'Un fichier ne correspond pas à ce dossier.', ['status'=>403]);\n\t\t}\n\n\t\t$payload = self::sanitize_payload_recursive($data);\n\t\tunset($payload['prenom'], $payload['nom'], $payload['sexe'], $payload['dateNaissance'], $payload['honeypot'], $payload['discussionClasse'], $payload['classeChoisieId'], $payload['classeChoisieCommentaire'], $payload['draftId'], $payload['draftToken']);\n\t\t$payload['classeRecommandee'] = ['id' => $classe_recommandee['id'], 'nom' => $classe_recommandee['nom'], 'niveau' => $classe_recommandee['niveau'] ?? null]; if (!empty($classe_recommandee['sousAgeMinimum'])) { $payload['admissionAge'] = ['sousMinimum' => true, 'ageMois' => (int)($classe_recommandee['ageMois'] ?? 0), 'message' => $classe_recommandee['avertissementAge'] ?? 'Moins de 9 mois — admission à confirmer avec l’équipe Yaaru.']; }\n\t\t$payload['classeChoisie'] = ['id' => $classe['id'], 'nom' => $classe['nom'], 'niveau' => $classe['niveau'] ?? null];\n\t\t$payload['classeChoixDifferent'] = $classe['id'] !== $classe_recommandee['id'];\n\t\t$payload['classeChoisieCommentaire'] = $classe_choisie_commentaire;\n\t\t$payload = Yaaru_Files::persist_payload_files($payload, 0, $draft_id);\n\t\tif (is_wp_error($payload)) return $payload;\n\t\t$old_payload = $existing_child ? (json_decode((string)$existing_child['payload'], true) ?: []) : [];\n\t\t$request = ($existing_child && !$converting_provisional) ? Yaaru_Submissions::latest_request($existing_child_id) : null;\n\t\t$requested_sections = ($request && $request['status'] === 'open') ? ($request['sections'] ?? []) : [];\n\t\tif ($existing_child && !$converting_provisional) {\n\t\t\tif (!$request || $request['status'] !== 'open') return new WP_Error('no_open_correction','Aucune correction n’est actuellement ouverte pour ce dossier.',['status'=>409]);\n\t\t\tif (in_array('autre', $requested_sections, true)) {\n\t\t\t\t// « Autre » est volontairement large : la direction assume l’ouverture complète.\n\t\t\t} else {\n\t\t\t\t$merged = $old_payload;\n\t\t\t\t$groups = [\n\t\t\t\t\t'enfant' => ['photo','lieuNaissance','nationalite','quartier','commune','ville','region','classeRecommandee','classeChoisie','classeChoixDifferent','classeChoisieCommentaire'],\n\t\t\t\t\t'formule' => ['formuleId','garderieMatin','garderieSoir','activites'],\n\t\t\t\t\t'famille' => ['parents','fratrie','remarquesFamille'],\n\t\t\t\t\t'personnes' => ['personnesAutorisees','engagementPersonnes'],\n\t\t\t\t\t'sante' => ['sante'],\n\t\t\t\t\t'autorisations' => ['autorisations'],\n\t\t\t\t\t'documents' => ['documents'],\n\t\t\t\t\t'signature' => ['signatures'],\n\t\t\t\t];\n\t\t\t\tforeach ($requested_sections as $section) foreach (($groups[$section] ?? []) as $key) {\n\t\t\t\t\tif (array_key_exists($key, $payload)) $merged[$key] = $payload[$key];\n\t\t\t\t}\n\t\t\t\t$payload = $merged;\n\t\t\t}\n\t\t\tif (!in_array('enfant', $requested_sections, true) && !in_array('autre', $requested_sections, true)) {\n\t\t\t\t$prenom = $existing_child['prenom']; $nom = $existing_child['nom']; $sexe = $existing_child['sexe']; $date_naissance = $existing_child['date_naissance'];\n\t\t\t\t$classe = Yaaru_Config::classe_by_id($existing_child['classe_id']) ?: $classe;\n\t\t\t}\n\t\t}\n\t\t$payload['historique'] = is_array($old_payload['historique'] ?? null) ? $old_payload['historique'] : [];\n\t\t$payload['historique'][] = [\n\t\t\t'date' => current_time('c'),\n\t\t\t'texte' => $existing_child ? \"Corrections renvoyées par la famille.\" : \"Dossier envoyé par la famille via le parcours d'inscription en ligne.\",\n\t\t];\n\n\t\t$now = current_time('mysql');\n\t\tif ($existing_child) {\n\t\t\t$ok = $wpdb->update($wpdb->prefix . 'yaaru_children', [\n\t\t\t\t'numero_dossier' => $numero,\n\t\t\t\t'prenom' => $prenom, 'nom' => $nom, 'sexe' => $sexe,\n\t\t\t\t'date_naissance' => $date_naissance, 'classe_id' => $classe['id'],\n\t\t\t\t'statut_dossier' => $converting_provisional ? 'soumis' : 'corrige', 'payload' => wp_json_encode($payload), 'updated_at' => $now,\n\t\t\t], ['id' => $existing_child_id]);\n\t\t\tif ($ok === false) return new WP_Error('db_error', 'Impossible d’enregistrer les corrections.', ['status'=>500]);\n\t\t\t$id = $existing_child_id;\n\t\t} else {\n\t\t\t$ok = $wpdb->insert($wpdb->prefix . 'yaaru_children', [\n\t\t\t\t'numero_dossier' => $numero,\n\t\t\t\t'prenom' => $prenom,\n\t\t\t\t'nom' => $nom,\n\t\t\t\t'sexe' => $sexe,\n\t\t\t\t'date_naissance' => $date_naissance,\n\t\t\t\t'classe_id' => $classe['id'],\n\t\t\t\t'statut_dossier' => 'soumis',\n\t\t\t\t'statut_place' => 'demande',\n\t\t\t\t'payload' => wp_json_encode($payload),\n\t\t\t\t'created_at' => $now,\n\t\t\t\t'updated_at' => $now,\n\t\t\t]);\n\t\t\tif (!$ok) return new WP_Error('db_error', 'Impossible d’enregistrer le dossier.', ['status'=>500]);\n\t\t\t$id = (int)$wpdb->insert_id;\n\t\t}\n\t\t$file_ids = self::collect_file_ids($payload);\n\t\tif ($file_ids) {\n\t\t\t$in = implode(',', array_map('intval', $f --- TRUNCATED (155,574 chars total, showing 50,000). To get the full response, increase max_length. To reduce response size, use the fields parameter or per_page. --- https://yaaru-ecole.com/post-sitemap.xml 2026-09-18T14:27:11+00:00 https://yaaru-ecole.com/page-sitemap.xml 2026-09-20T10:20:39+00:00 https://yaaru-ecole.com/category-sitemap.xml 2026-09-18T14:27:11+00:00