entity_type(), $this->record_for( $request ) ) ? true : $this->forbidden(); } /** * Permission callback for create routes. * * @param WP_REST_Request $request The request. */ public function can_create( WP_REST_Request $request ): bool|WP_Error { unset( $request ); return Capabilities::user_can( 'create', $this->entity_type() ) ? true : $this->forbidden(); } /** * Permission callback for update routes. * * ALSO THE CONCURRENCY CHECK, because this is the one place in the plugin that has both the * request and the current row in hand for every entity — `record_for()` has already loaded it to * answer the record-level permission question. Refusing here costs no service or repository * changes and covers every controller at once. See {@see Concurrency} for why a save that * silently overwrites somebody else's is worth a route of its own to prevent. * * Permission first: whether you may touch this record at all is a more fundamental answer than * whether your copy of it is current, and a user who may not edit it should not learn from the * error message that somebody else just did. * * @param WP_REST_Request $request The request. */ public function can_edit( WP_REST_Request $request ): bool|WP_Error { /* * THE VERSION CHECK ONLY WORKS ONE SAVE AT A TIME, SO SAVES OF ONE RECORD ARE MADE ONE AT A TIME. * * Thirty editors saving the same stale copy at once all loaded the record before any of them wrote, * all matched, and twelve were accepted (E2E run, 2026-09-11). A request that carries a version now * takes the record's lock BEFORE loading it and keeps it until the response is sent, so the next one * loads the row the first one wrote and is refused. Requests without a version take no lock — they * asked for last-write-wins, and serialising them would only add waiting. */ $id = (int) ( $request['id'] ?? 0 ); if ( $id > 0 && Concurrency::carries_version( $request ) && ! Lock::hold( 'edit:' . $this->entity_type() . ':' . $id ) ) { return Lock::busy(); } $record = $this->record_for( $request ); if ( ! Capabilities::user_can( 'edit', $this->entity_type(), $record ) ) { return $this->forbidden(); } return Concurrency::check( $request, $record ); } /** * Permission callback for delete routes. * * @param WP_REST_Request $request The request. */ public function can_delete( WP_REST_Request $request ): bool|WP_Error { return Capabilities::user_can( 'delete', $this->entity_type(), $this->record_for( $request ) ) ? true : $this->forbidden(); } /** * Load the record a request targets, so record-level checks can see it. * * Overridden by controllers that operate on a single record. * * @param WP_REST_Request $request The request. * * @return array|null */ protected function record_for( WP_REST_Request $request ): ?array { unset( $request ); return null; } /** * A successful response. * * @param mixed $data Payload. * @param array $meta Pagination or other metadata. * @param int $status HTTP status. */ protected function ok( mixed $data, array $meta = array(), int $status = 200 ): WP_REST_Response { /** * Filter a successful payload before it leaves the building. * * THE ONE PLACE FIELD-LEVEL ACCESS CAN BE ENFORCED. Every route in the plugin answers * through this method, so a rule applied here cannot be forgotten by a controller — and a * field-level rule that one controller forgets is a field quietly readable through one * route, which looks exactly like a field that was allowed. * * Core has no opinion about which fields those are. The Roles module answers this. * * @param mixed $data The payload. * @param string $entity_type The controller's entity slug. */ $data = apply_filters( 'plugixa_crm_filter_output', $data, $this->entity_type() ); $body = array( 'success' => true, 'data' => $data, ); if ( $meta ) { $body['meta'] = $meta; } return new WP_REST_Response( $body, $status ); } /** * An error response. * * @param string $code Machine-readable code. * @param string $message Human-readable message. * @param int $status HTTP status. */ protected function fail( string $code, string $message, int $status = 400 ): WP_Error { return new WP_Error( $code, $message, array( 'status' => $status ) ); } /** * The standard 403. */ protected function forbidden(): WP_Error { return $this->fail( 'plugixa_crm_forbidden', __( 'You are not allowed to do that.', 'plugixa-crm' ), rest_authorization_required_code() ); } /** * Pagination arguments, clamped. * * @param WP_REST_Request $request The request. * * @return array{limit: int, offset: int, orderby: string, order: string, search: string} */ protected function list_args( WP_REST_Request $request ): array { $per_page = (int) ( $request->get_param( 'per_page' ) ?? 50 ); $page = (int) ( $request->get_param( 'page' ) ?? 1 ); $per_page = max( 1, min( 200, $per_page ) ); $page = max( 1, $page ); return array( 'limit' => $per_page, 'offset' => ( $page - 1 ) * $per_page, 'orderby' => (string) ( $request->get_param( 'orderby' ) ?? 'id' ), 'order' => 'asc' === strtolower( (string) $request->get_param( 'order' ) ) ? 'ASC' : 'DESC', 'search' => sanitize_text_field( (string) ( $request->get_param( 'search' ) ?? '' ) ), ); } /** * A paged, searchable, trash-aware list — the shape `EntityList` requires. * * WHY THIS IS IN CORE. Five controllers had written, in full: * * unset( $request ); * return $this->ok( $this->service->all() ); * * No paging, no search, no `view=trash`, and a bare array rather than the `{items, total}` * envelope — so none of them could be rendered by `EntityList`, which is why several lists * in the plugin were hand-rolled tables with none of its behaviour. Other controllers each * grew their own correct version instead, which is four more copies of the same twenty lines. * Nine more document modules are about to need it. * * The two things that are genuinely per-entity stay parameters: which columns a search looks in, * and any extra narrowing. Everything else — the clamped paging, the trash view, the safe sort * column, the total, the page count — is the same everywhere and is decided here. * * `$narrow` may return a `WP_Error` to REFUSE the request rather than narrow it. That matters * for a filter that defines what a page is: silently ignoring an unrecognised value turns a * screen showing one kind of record into one showing every kind, which looks like a working page. * * `$decorate` adds whatever a row needs that is not a column — the members on a team, the * `editable` flag a list reads to decide whether to draw a pencil. Without it this returns the * table and nothing else, which is a silent regression for any list that was showing more than * the table: the column simply renders empty. * * @param WP_REST_Request $request The request. * @param Repository $repository The entity's repository. * @param string[] $search_columns Columns a search matches, e.g. `array( 't.name' )`. * @param callable|null $narrow fn(QueryBuilder, WP_REST_Request): ?WP_Error. * @param callable|null $decorate fn(array $row): array, applied to every row. */ protected function paged_list( WP_REST_Request $request, Repository $repository, array $search_columns = array(), ?callable $narrow = null, ?callable $decorate = null ): WP_REST_Response|WP_Error { $args = $this->list_args( $request ); $query = $repository->query(); if ( 'trash' === $request->get_param( 'view' ) ) { $query->only_trashed(); } if ( null !== $narrow ) { $refused = $narrow( $query, $request ); if ( $refused instanceof WP_Error ) { return $refused; } } // BEFORE count(), which is the next line and must see the same conditions the page will. // The empty-term and empty-column guards moved into search() — and dropping the second one // here is deliberate: a list whose entity declares no searchable column of its own can still // gain one from a module, and the old guard would have skipped the whole clause. $query->search( $args['search'], $search_columns ); $total = $query->count(); $rows = $query ->order_by_column( $repository->safe_sort_column( $args['orderby'] ), $args['order'] ) ->paginate( $args['limit'], $args['offset'] ) ->get(); // Cast when the repository knows how. MySQL hands every column back as a string, and "0" is // truthy in JavaScript — a client testing an id to decide whether to show something is right // about the rows that have one and wrong about the rest. if ( method_exists( $repository, 'cast' ) ) { $rows = array_map( array( $repository, 'cast' ), $rows ); } if ( null !== $decorate ) { $rows = array_map( $decorate, $rows ); } return $this->ok( $rows, array( 'total' => $total, 'pages' => (int) ceil( $total / max( 1, $args['limit'] ) ), ) ); } /** * The collection query parameters every list route accepts. * * @return array */ public function get_collection_params(): array { return array( 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), 'per_page' => array( 'type' => 'integer', 'default' => 50, 'minimum' => 1, 'maximum' => 200, ), 'search' => array( 'type' => 'string' ), 'orderby' => array( 'type' => 'string' ), 'order' => array( 'type' => 'string', 'enum' => array( 'asc', 'desc' ), ), ); } }