true, 'type' => 'string', 'sanitize_callback' => static function ( $v ): string { if ( ! is_string( $v ) ) { return ''; } $s = strtolower( (string) preg_replace( '/[^a-zA-Z0-9_\-]/', '', $v ) ); return strlen( $s ) > 60 ? substr( $s, 0, 60 ) : $s; }, 'validate_callback' => static function ( $v ): bool { return is_string( $v ) && preg_match( '/^[a-zA-Z0-9_\-]{1,60}$/', $v ) === 1; }, ]; register_rest_route( $this->namespace, '/shared-calendars', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_items' ], 'permission_callback' => [ $this, 'check_read_permission' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'create_item' ], 'permission_callback' => [ $this, 'check_manage_permission' ], 'args' => [ 'login' => $slug_arg, 'name' => $this->rest_arg_string( 200, true ), 'isPublic' => $this->rest_arg_bool( false ), ], ], ] ); register_rest_route( $this->namespace, '/shared-calendars/(?P[a-zA-Z0-9_\-]+)', [ [ 'methods' => 'PUT', 'callback' => [ $this, 'update_item' ], 'permission_callback' => [ $this, 'check_manage_permission' ], 'args' => [ 'login' => $slug_arg, 'name' => $this->rest_arg_string( 200, false ), 'isPublic' => $this->rest_arg_bool( false ), ], ], [ 'methods' => 'DELETE', 'callback' => [ $this, 'delete_item' ], 'permission_callback' => [ $this, 'check_manage_permission' ], 'args' => [ 'login' => $slug_arg, ], ], ] ); } /** * Check if user can read shared calendars (any authenticated user). */ public function check_read_permission(): bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } return $this->check_permission( 'webcal_can_view' ); } /** * Check if user can manage shared calendars (admin only). */ public function check_manage_permission(): bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } return $this->check_permission( 'webcal_can_manage_shared_calendars' ); } /** * Get all shared calendars (resources with NULL url). */ public function get_items( $request ): WP_REST_Response { $repo = $this->container->get_resource_repository(); $resources = $repo->findAll(); $data = []; foreach ( $resources as $resource ) { if ( $resource->url() === null ) { $data[] = $this->prepare_resource( $resource ); } } return new WP_REST_Response( $data, 200 ); } /** * Create a new shared calendar. */ public function create_item( $request ): WP_REST_Response|WP_Error { // Schema (see register_routes(), WPORG-7.8) has already sanitized // the slug (lowercase alphanumeric/_- only), sanitize_text_field'd // the name, and coerced isPublic to a real boolean. The slug-format // guard below becomes defence-in-depth for tests that bypass the // schema; $this->str stays for PHPStan Level 9 mixed→string coercion. $login = $this->str( $request->get_param( 'login' ) ); $name = $this->str( $request->get_param( 'name' ) ); $public = ! empty( $request->get_param( 'isPublic' ) ); if ( empty( $login ) || empty( $name ) ) { return new WP_Error( 'missing_params', __( 'Login and name are required.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } // Validate slug format $login = strtolower( preg_replace( '/[^a-z0-9_\-]/', '', $login ) ?? '' ); if ( empty( $login ) ) { return new WP_Error( 'invalid_login', __( 'Invalid Calendar ID. Use only letters, numbers, underscores and hyphens.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } $repo = $this->container->get_resource_repository(); if ( $repo->findByLogin( $login ) !== null ) { return new WP_Error( 'duplicate_login', __( 'A calendar with this ID already exists.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } $resource = new Resource( login: $login, name: $name, admin: wp_get_current_user()->user_login, isPublic: $public, ); $repo->save( $resource ); return new WP_REST_Response( $this->prepare_resource( $resource ), 201 ); } /** * Update a shared calendar. */ public function update_item( $request ): WP_REST_Response|WP_Error { $login = $this->str( $request->get_param( 'login' ) ); $repo = $this->container->get_resource_repository(); $existing = $repo->findByLogin( $login ); if ( ! $existing || $existing->url() !== null ) { return new WP_Error( 'not_found', __( 'Shared calendar not found.', 'agenticdaisy-calendar' ), [ 'status' => 404 ] ); } // Schema sanitizes name (WPORG-7.8); $this->str() stays for PHPStan Level 9. $name = $this->str( $request->get_param( 'name' ) ?? $existing->name() ); $public = $request->get_param( 'isPublic' ) !== null ? ! empty( $request->get_param( 'isPublic' ) ) : $existing->isPublic(); $resource = new Resource( login: $login, name: $name, admin: $existing->admin(), isPublic: $public, ); $repo->save( $resource ); return new WP_REST_Response( $this->prepare_resource( $resource ), 200 ); } /** * Delete a shared calendar and its events. */ public function delete_item( $request ): WP_REST_Response|WP_Error { $login = $this->str( $request->get_param( 'login' ) ); $repo = $this->container->get_resource_repository(); $existing = $repo->findByLogin( $login ); if ( ! $existing || $existing->url() !== null ) { return new WP_Error( 'not_found', __( 'Shared calendar not found.', 'agenticdaisy-calendar' ), [ 'status' => 404 ] ); } // Delete the resource and its events $event_service = $this->container->get_event_service(); $event_repo = $this->container->get_event_repository(); $range = new \WebCalendar\Core\Domain\ValueObject\DateRange( new \DateTimeImmutable( '1970-01-01' ), new \DateTimeImmutable( '2099-12-31' ) ); // Deleting the calendar must take every entry on it with it, whatever // its access level, or the rows outlive the calendar they belonged to. $events = $event_service->getEventsInDateRange( $range, \WebCalendar\Core\Domain\ValueObject\EventScope::administrative()->limitedToUsers( [ $login ] ) ); // An entry with a ticket order holding money cannot go until it is // cancelled or refunded; the calendar stays until then. Entries // already deleted stay deleted. try { foreach ( $events as $event ) { $event_service->deleteEvent( $event->id(), $this->get_current_actor() ); \WebCalendar\WP\Database\EventSideData::purge( $event->id()->value() ); } } catch ( \DomainException $e ) { return $this->delete_refused( $e ); } $repo->delete( $login ); return new WP_REST_Response( [ 'deleted' => true ], 200 ); } /** * Prepare resource for JSON response. * * @param Resource $resource The resource entity. * @return array */ private function prepare_resource( Resource $resource ): array { return [ 'login' => $resource->login(), 'name' => esc_html( $resource->name() ), 'isPublic' => $resource->isPublic(), 'admin' => $resource->admin(), ]; } }