namespace, '/venues', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_venues' ], 'permission_callback' => [ $this, 'get_venues_permissions_check' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'create_venue' ], 'permission_callback' => [ $this, 'manage_venues_permissions_check' ], 'args' => $this->venue_args( true ), ], ] ); register_rest_route( $this->namespace, '/venues/(?P\d+)', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_venue' ], // The full record (address, contact details) is the // management screen's view; only the id/name list is public. 'permission_callback' => [ $this, 'manage_venues_permissions_check' ], ], [ 'methods' => 'PUT, PATCH', 'callback' => [ $this, 'update_venue' ], 'permission_callback' => [ $this, 'manage_venues_permissions_check' ], 'args' => $this->venue_args( false ), ], [ 'methods' => 'DELETE', 'callback' => [ $this, 'delete_venue' ], 'permission_callback' => [ $this, 'manage_venues_permissions_check' ], ], ] ); } /** * Args shared by create and update. * * @param bool $creating Whether a name is required. * @return array> * * @psalm-suppress UnusedMethod -- Invoked during register_routes(). */ private function venue_args( bool $creating ): array { return [ 'name' => $this->rest_arg_string( 100, $creating ), 'address' => $this->rest_arg_string( 255, false ), 'city' => $this->rest_arg_string( 100, false ), 'state' => $this->rest_arg_string( 50, false ), 'zip' => $this->rest_arg_string( 20, false ), 'country' => $this->rest_arg_string( 100, false ), 'url' => $this->rest_arg_url( false, true ), 'phone' => $this->rest_arg_string( 40, false ), // Null as well as number: a venue without coordinates reads back // with latitude null, and an edit screen sends the record it was // given. Typed as number alone, the API rejected its own GET // payload and no venue lacking coordinates could be saved. 'latitude' => [ 'required' => false, 'type' => [ 'number', 'null' ], ], 'longitude' => [ 'required' => false, 'type' => [ 'number', 'null' ], ], ]; } /** * Creating, renaming and removing venues is a management action. * * @psalm-suppress PossiblyUnusedMethod -- Called by the WP REST API as a permission callback. */ public function manage_venues_permissions_check( WP_REST_Request $request ): 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_categories' ); } /** * Anyone who may view the calendar may see where its events are. */ public function get_venues_permissions_check( WP_REST_Request $request ): bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } // Public read, matching GET /events: the Filter Bar on a public page // needs this list for logged-out visitors, and it hides its controls // when the list is empty — so a 403 here removed the feature entirely // from the calendars it was built for. Only ids and names are // exposed; addresses, coordinates and contact details are not. if ( ! is_user_logged_in() ) { return true; } return $this->check_permission( 'webcal_can_view' ); } /** * List every venue as id + name. * * @psalm-suppress PossiblyUnusedParam -- The REST framework always passes $request. */ public function get_venues( WP_REST_Request $request ): WP_REST_Response|WP_Error { try { $repo = $this->container->get_venue_repository(); $venues = $repo->findAll(); // The management screen needs to know what a venue is holding // before it offers to delete it. Everyone else gets the picker's // payload: this list is public so the Filter Bar works logged // out, and how busy each venue is is not a dropdown's business. $counts = current_user_can( 'webcal_can_manage_categories' ); return new WP_REST_Response( array_values( array_map( static function ( $venue ) use ( $counts, $repo ): array { $row = [ 'id' => $venue->id()->value(), 'name' => $venue->name(), ]; if ( $counts ) { $row['event_count'] = $repo->countEvents( $venue->id() ); } return $row; }, $venues ) ), 200 ); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in VenuesController (get_venues): ' . esc_html( $e->getMessage() ) ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * One venue, in full. * * @psalm-suppress PossiblyUnusedMethod -- Called by the WP REST API as a route callback. * @psalm-suppress PossiblyUnusedParam -- The REST framework always passes $request. */ public function get_venue( WP_REST_Request $request ): WP_REST_Response|WP_Error { try { $venue = $this->container->get_venue_repository()->findById( new \WebCalendar\Core\Domain\ValueObject\VenueId( $this->int( $request->get_param( 'id' ) ) ) ); if ( null === $venue ) { return new WP_Error( 'webcal_not_found', __( 'Venue not found', 'agenticdaisy-calendar' ), [ 'status' => 404 ] ); } return new WP_REST_Response( self::to_array( $venue ), 200 ); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in VenuesController (get_venue): ' . esc_html( $e->getMessage() ) ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * Create a venue. * * @psalm-suppress PossiblyUnusedMethod -- Called by the WP REST API as a route callback. * @psalm-suppress PossiblyUnusedParam -- The REST framework always passes $request. */ public function create_venue( WP_REST_Request $request ): WP_REST_Response|WP_Error { $name = trim( $this->str( $request->get_param( 'name' ) ) ); if ( '' === $name ) { return new WP_Error( 'rest_invalid_param', __( 'A venue needs a name.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } try { $repo = $this->container->get_venue_repository(); $venue = new \WebCalendar\Core\Domain\Entity\Venue( // Id 0 means "new". save() allocates the id inside its own // transaction and hands back the persisted entity; passing an // id from nextId() instead makes save() take its update path, // which updates nothing and reports success. id: new \WebCalendar\Core\Domain\ValueObject\VenueId( 0 ), name: $name, address: $this->nullable( $request->get_param( 'address' ) ), city: $this->nullable( $request->get_param( 'city' ) ), state: $this->nullable( $request->get_param( 'state' ) ), zip: $this->nullable( $request->get_param( 'zip' ) ), country: $this->nullable( $request->get_param( 'country' ) ), latitude: $this->nullable_float( $request->get_param( 'latitude' ) ), longitude: $this->nullable_float( $request->get_param( 'longitude' ) ), url: $this->nullable( $request->get_param( 'url' ) ), phone: $this->nullable( $request->get_param( 'phone' ) ), ); return new WP_REST_Response( self::to_array( $repo->save( $venue ) ), 201 ); } catch ( \InvalidArgumentException $e ) { // The entity validates its own coordinates: one of lat/lon without // the other, or a value off the globe, is a bad request rather // than a server fault. return new WP_Error( 'rest_invalid_param', $e->getMessage(), [ 'status' => 400 ] ); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in VenuesController (create_venue): ' . esc_html( $e->getMessage() ) ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * Update a venue, leaving out what the request did not mention. * * Rebuilding the record from only the submitted fields is how events came * to lose their venue on a rename; a venue must not lose its address the * same way. * * @psalm-suppress PossiblyUnusedMethod -- Called by the WP REST API as a route callback. * @psalm-suppress PossiblyUnusedParam -- The REST framework always passes $request. */ public function update_venue( WP_REST_Request $request ): WP_REST_Response|WP_Error { try { $repo = $this->container->get_venue_repository(); $venue_id = new \WebCalendar\Core\Domain\ValueObject\VenueId( $this->int( $request->get_param( 'id' ) ) ); $existing = $repo->findById( $venue_id ); if ( null === $existing ) { return new WP_Error( 'webcal_not_found', __( 'Venue not found', 'agenticdaisy-calendar' ), [ 'status' => 404 ] ); } $name = null === $request->get_param( 'name' ) ? $existing->name() : trim( $this->str( $request->get_param( 'name' ) ) ); if ( '' === $name ) { return new WP_Error( 'rest_invalid_param', __( 'A venue needs a name.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } $venue = new \WebCalendar\Core\Domain\Entity\Venue( id: $venue_id, name: $name, address: $this->kept( $request, 'address', $existing->address() ), city: $this->kept( $request, 'city', $existing->city() ), state: $this->kept( $request, 'state', $existing->state() ), zip: $this->kept( $request, 'zip', $existing->zip() ), country: $this->kept( $request, 'country', $existing->country() ), latitude: null === $request->get_param( 'latitude' ) ? $existing->latitude() : $this->nullable_float( $request->get_param( 'latitude' ) ), longitude: null === $request->get_param( 'longitude' ) ? $existing->longitude() : $this->nullable_float( $request->get_param( 'longitude' ) ), url: $this->kept( $request, 'url', $existing->url() ), phone: $this->kept( $request, 'phone', $existing->phone() ), ); return new WP_REST_Response( self::to_array( $repo->save( $venue ) ), 200 ); } catch ( \InvalidArgumentException $e ) { return new WP_Error( 'rest_invalid_param', $e->getMessage(), [ 'status' => 400 ] ); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in VenuesController (update_venue): ' . esc_html( $e->getMessage() ) ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * Delete a venue, optionally moving its events to another one first. * * Without reassign_to the events keep an id that no longer resolves, * which reads as "no venue" everywhere it is rendered. That is a * deliberate choice over refusing the delete: a venue nobody uses should * not be undeletable because one old event mentions it. * * @psalm-suppress PossiblyUnusedMethod -- Called by the WP REST API as a route callback. * @psalm-suppress PossiblyUnusedParam -- The REST framework always passes $request. */ public function delete_venue( WP_REST_Request $request ): WP_REST_Response|WP_Error { try { $repo = $this->container->get_venue_repository(); $venue_id = new \WebCalendar\Core\Domain\ValueObject\VenueId( $this->int( $request->get_param( 'id' ) ) ); if ( null === $repo->findById( $venue_id ) ) { return new WP_Error( 'webcal_not_found', __( 'Venue not found', 'agenticdaisy-calendar' ), [ 'status' => 404 ] ); } $reassign_raw = $request->get_param( 'reassign_to' ); $reassign = $this->int( $reassign_raw ); if ( $reassign > 0 ) { if ( $reassign === $venue_id->value() ) { return new WP_Error( 'rest_invalid_param', __( 'A venue cannot be reassigned to itself.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } $target = new \WebCalendar\Core\Domain\ValueObject\VenueId( $reassign ); if ( null === $repo->findById( $target ) ) { return new WP_Error( 'rest_invalid_param', __( 'No venue with that id exists.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } $repo->reassignEvents( $venue_id, $target ); } $repo->delete( $venue_id ); return new WP_REST_Response( [ 'deleted' => true ], 200 ); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in VenuesController (delete_venue): ' . esc_html( $e->getMessage() ) ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * The full venue record, as the event payload also renders it. * * @return array * * @psalm-suppress UnusedMethod -- Called from every route callback in this class. */ private static function to_array( \WebCalendar\Core\Domain\Entity\Venue $venue ): array { return [ 'id' => $venue->id()->value(), 'name' => $venue->name(), 'address' => $venue->address(), 'city' => $venue->city(), 'state' => $venue->state(), 'zip' => $venue->zip(), 'country' => $venue->country(), 'latitude' => $venue->latitude(), 'longitude' => $venue->longitude(), 'url' => $venue->url(), 'phone' => $venue->phone(), ]; } }