\WP_REST_Server::READABLE, 'callback' => [ $this, 'index' ], 'permission_callback' => [ $this, 'canBook' ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'create' ], 'permission_callback' => [ $this, 'canManage' ], ], ] ); register_rest_route( $route_namespace, '/policies/(?P\d+)/versions', [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'addVersion' ], 'permission_callback' => [ $this, 'canManage' ], ], ] ); register_rest_route( $route_namespace, '/policies/(?P\d+)/versions/(?P\d+)', [ [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'updateVersion' ], 'permission_callback' => [ $this, 'canManage' ], ], ] ); register_rest_route( $route_namespace, '/policies/(?P\d+)/versions/(?P\d+)/publish', [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'publish' ], 'permission_callback' => [ $this, 'canManage' ], ], ] ); } /** * Public: the current published version of every policy (the registration * gate). Pass `?scope=signup|booking` to limit to that gate (includes * `both`-scoped policies). */ public function index( \WP_REST_Request $request ): \WP_REST_Response { $scope = (string) $request->get_param( 'scope' ); $policies = in_array( $scope, [ Policy::SCOPE_SIGNUP, Policy::SCOPE_BOOKING ], true ) ? $this->policies->findForScope( $scope ) : $this->policies->findAll(); $out = []; foreach ( $policies as $policy ) { if ( null === $policy->currentVersionId ) { continue; } $version = $this->versions->findById( $policy->currentVersionId ); if ( null === $version || ! $version->isPublished() ) { continue; } $out[] = [ 'id' => $policy->id, 'title' => $policy->title, 'slug' => $policy->slug, 'policy_version_id' => $version->id, 'version_number' => $version->versionNumber, // Bodies are kses'd on every write path, but the booking JS renders // this HTML raw — sanitise at output too so a missed write path can // never become stored XSS. 'body' => wp_kses_post( (string) $version->body ), ]; } return new \WP_REST_Response( $out, 200 ); } public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $title = sanitize_text_field( (string) $request->get_param( 'title' ) ); if ( '' === $title ) { return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) ); } $slugParam = sanitize_text_field( (string) $request->get_param( 'slug' ) ); $slug = sanitize_title( '' !== $slugParam ? $slugParam : $title ); if ( '' === $slug ) { return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) ); } if ( null !== $this->policies->findBySlug( $slug ) ) { return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } $scope = (string) ( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING ); if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) { return $this->invalid( __( 'Invalid acceptance scope.', 'unsupervised-schedular' ) ); } $id = $this->service->createPolicy( $title, $slug, $scope ); return new \WP_REST_Response( [ 'id' => $id ], 201 ); } public function addVersion( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $policy = $this->policies->findById( absint( $request->get_param( 'id' ) ) ); if ( null === $policy ) { return $this->notFound(); } $body = wp_kses_post( (string) $request->get_param( 'body' ) ); $id = $this->service->addDraftVersion( (int) $policy->id, $body ); return new \WP_REST_Response( [ 'id' => $id ], 201 ); } public function updateVersion( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $version = $this->loadVersionForPolicy( $request ); if ( $version instanceof \WP_Error ) { return $version; } if ( PolicyVersion::STATUS_DRAFT !== $version->status ) { return $this->invalid( __( 'Only draft versions can be edited.', 'unsupervised-schedular' ) ); } $body = wp_kses_post( (string) $request->get_param( 'body' ) ); $this->versions->updateBody( (int) $version->id, $body ); return new \WP_REST_Response( [ 'id' => $version->id, 'body' => $body, ], 200 ); } public function publish( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $version = $this->loadVersionForPolicy( $request ); if ( $version instanceof \WP_Error ) { return $version; } $this->service->publishVersion( (int) $request->get_param( 'id' ), (int) $version->id ); return new \WP_REST_Response( [ 'id' => $version->id, 'status' => PolicyVersion::STATUS_PUBLISHED, ], 200 ); } public function canManage(): bool { return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_POLICIES ); } /** * The published-policy listing is only read by the logged-in student * booking/enrolment flow (the signup gate renders its policies server-side, not * via this endpoint), so reading it requires the booking capability — there is * no anonymous consumer. */ public function canBook(): bool { return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); } /** * Load the version named in the route and confirm it belongs to the policy. */ private function loadVersionForPolicy( \WP_REST_Request $request ): PolicyVersion|\WP_Error { $policyId = absint( $request->get_param( 'id' ) ); $version = $this->versions->findById( absint( $request->get_param( 'vid' ) ) ); if ( null === $version || $version->policyId !== $policyId ) { return $this->notFound(); } return $version; } private function notFound(): \WP_Error { return new \WP_Error( 'not_found', __( 'Policy or version not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } private function invalid( string $message ): \WP_Error { return new \WP_Error( 'invalid_policy', $message, [ 'status' => 400 ] ); } }