get_method() : 'GET'; $rate = WC_EDM_Rate_Limiter::check( $method ); if ( $rate['limited'] ) { return new WP_Error( 'wc_edm_rate_limited', __( 'Too many requests. Please slow down.', 'wc-email-discount' ), array( 'status' => 429, 'retry_after' => $rate['retry_after'], ) ); } if ( current_user_can( 'manage_woocommerce' ) ) { return true; } return new WP_Error( 'wc_edm_forbidden', __( 'You are not allowed to manage email discounts.', 'wc-email-discount' ), array( 'status' => rest_authorization_required_code() ) ); } /** * Write an audit entry for wc-edm requests. * * Runs after the route callback (and also when a permission check failed), * so both successful writes and rejected attempts are recorded. Successful * reads are skipped to keep the log signal-heavy. * * @param WP_REST_Response|WP_HTTP_Response|WP_Error $response Result to send. * @param array $handler Route handler. * @param WP_REST_Request $request Current request. * @return mixed The unmodified response. */ public function log_request( $response, $handler, $request ) { if ( ! $request instanceof WP_REST_Request ) { return $response; } $route = $request->get_route(); if ( 0 !== strpos( ltrim( $route, '/' ), self::REST_NAMESPACE ) ) { return $response; } $method = $request->get_method(); $is_write = WC_EDM_Rate_Limiter::is_write_method( $method ); $details = array(); $outcome = 'allowed'; $status = 200; if ( is_wp_error( $response ) ) { $error_data = $response->get_error_data(); $status = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : 500; $outcome = 'wc_edm_rate_limited' === $response->get_error_code() ? 'rate_limited' : 'denied'; $details['code'] = $response->get_error_code(); } elseif ( $response instanceof WP_HTTP_Response ) { $status = $response->get_status(); if ( $status >= 400 ) { $outcome = 'denied'; $data = $response->get_data(); $details['code'] = is_array( $data ) && isset( $data['code'] ) ? $data['code'] : 'error'; } } // Successful reads are noise; everything else is worth keeping. if ( 'allowed' === $outcome && ! $is_write ) { return $response; } // Don't let a blocked caller flood the log with identical rows. if ( 'rate_limited' === $outcome && ! WC_EDM_Rate_Limiter::should_log_limit_hit() ) { return $response; } WC_EDM_Audit_Log::record( $outcome, $method, $route, $status, $details ); return $response; } /** * Register all routes. */ public function register_routes() { $perm = array( $this, 'permission_check' ); register_rest_route( self::REST_NAMESPACE, '/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_status' ), 'permission_callback' => $perm, ) ); register_rest_route( self::REST_NAMESPACE, '/groups', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_groups' ), 'permission_callback' => $perm, ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_group' ), 'permission_callback' => $perm, ), ) ); register_rest_route( self::REST_NAMESPACE, '/groups/(?P\d+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_group' ), 'permission_callback' => $perm, ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_group' ), 'permission_callback' => $perm, ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_group' ), 'permission_callback' => $perm, ), ) ); register_rest_route( self::REST_NAMESPACE, '/emails', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_emails' ), 'permission_callback' => $perm, ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_email' ), 'permission_callback' => $perm, ), ) ); register_rest_route( self::REST_NAMESPACE, '/emails/(?P\d+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_email' ), 'permission_callback' => $perm, ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_email' ), 'permission_callback' => $perm, ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_email' ), 'permission_callback' => $perm, ), ) ); register_rest_route( self::REST_NAMESPACE, '/shipping-methods', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_shipping_methods' ), 'permission_callback' => $perm, ) ); register_rest_route( self::REST_NAMESPACE, '/groups/(?P\d+)/shipping-rules', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_shipping_rules' ), 'permission_callback' => $perm, ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_shipping_rules' ), 'permission_callback' => $perm, ), ) ); register_rest_route( self::REST_NAMESPACE, '/audit-log', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_audit_log' ), 'permission_callback' => $perm, 'args' => array( 'limit' => array( 'type' => 'integer', 'default' => 50, ), 'offset' => array( 'type' => 'integer', 'default' => 0, ), 'outcome' => array( 'type' => 'string', 'enum' => array( 'allowed', 'denied', 'rate_limited' ), ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/match', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'match_email' ), 'permission_callback' => $perm, 'args' => array( 'email' => array( 'required' => true, 'type' => 'string', ), ), ) ); } /* |-------------------------------------------------------------------------- | Formatters |-------------------------------------------------------------------------- */ /** * Format a group row for API output. * * @param object $group Group row. * @return array */ private function format_group( $group ) { return array( 'id' => (int) $group->id, 'name' => $group->name, 'discount_percent' => (float) $group->discount_percent, 'category_ids' => WC_EDM_DB::get_group_category_ids( (int) $group->id ), 'created_at' => $group->created_at, 'updated_at' => $group->updated_at, ); } /** * Format an email assignment row for API output. * * @param object $assignment Email row. * @return array */ private function format_email( $assignment ) { return array( 'id' => (int) $assignment->id, 'email_pattern' => $assignment->email_pattern, 'assignment_type' => $assignment->assignment_type, 'group_id' => $assignment->group_id ? (int) $assignment->group_id : null, 'coupon_code' => $assignment->coupon_code, 'custom_discount' => null !== $assignment->custom_discount ? (float) $assignment->custom_discount : null, 'is_active' => (bool) $assignment->is_active, 'free_shipping' => ! empty( $assignment->free_shipping ), 'created_at' => $assignment->created_at, 'updated_at' => $assignment->updated_at, ); } /** * Standard 404 error. * * @return WP_Error */ private function not_found() { return new WP_Error( 'wc_edm_not_found', __( 'Resource not found.', 'wc-email-discount' ), array( 'status' => 404 ) ); } /* |-------------------------------------------------------------------------- | Status |-------------------------------------------------------------------------- */ /** * GET /status — connectivity test plus quick stats. * * @return WP_REST_Response */ public function get_status() { return rest_ensure_response( array( 'plugin' => 'woocommerce-email-discount-manager', 'version' => WC_EDM_VERSION, 'woocommerce' => defined( 'WC_VERSION' ) ? WC_VERSION : null, 'groups' => WC_EDM_DB::count_groups(), 'email_assignments' => WC_EDM_DB::count_emails(), 'active_assignments' => WC_EDM_DB::count_emails( true ), ) ); } /* |-------------------------------------------------------------------------- | Groups |-------------------------------------------------------------------------- */ /** * GET /groups * * @return WP_REST_Response */ public function get_groups() { return rest_ensure_response( array_map( array( $this, 'format_group' ), WC_EDM_DB::get_groups() ) ); } /** * GET /groups/{id} * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_group( $request ) { $group = WC_EDM_DB::get_group( (int) $request['id'] ); return $group ? rest_ensure_response( $this->format_group( $group ) ) : $this->not_found(); } /** * POST /groups * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_group( $request ) { $name = sanitize_text_field( (string) $request['name'] ); if ( '' === $name ) { return new WP_Error( 'wc_edm_invalid', __( 'A group name is required.', 'wc-email-discount' ), array( 'status' => 400 ) ); } $id = WC_EDM_DB::insert_group( array( 'name' => $name, 'discount_percent' => WC_EDM_Helpers::sanitize_percent( $request['discount_percent'] ?? 0 ), ) ); if ( ! $id ) { return new WP_Error( 'wc_edm_db_error', __( 'Could not create the group.', 'wc-email-discount' ), array( 'status' => 500 ) ); } if ( is_array( $request['category_ids'] ) ) { WC_EDM_DB::set_group_categories( $id, array_map( 'absint', $request['category_ids'] ) ); } return rest_ensure_response( $this->format_group( WC_EDM_DB::get_group( $id ) ) ); } /** * PUT/PATCH /groups/{id} * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_group( $request ) { $id = (int) $request['id']; $group = WC_EDM_DB::get_group( $id ); if ( ! $group ) { return $this->not_found(); } $name = null !== $request['name'] ? sanitize_text_field( (string) $request['name'] ) : $group->name; $percent = null !== $request['discount_percent'] ? WC_EDM_Helpers::sanitize_percent( $request['discount_percent'] ) : (float) $group->discount_percent; if ( '' === $name ) { return new WP_Error( 'wc_edm_invalid', __( 'A group name is required.', 'wc-email-discount' ), array( 'status' => 400 ) ); } WC_EDM_DB::update_group( $id, array( 'name' => $name, 'discount_percent' => $percent, ) ); if ( is_array( $request['category_ids'] ) ) { WC_EDM_DB::set_group_categories( $id, array_map( 'absint', $request['category_ids'] ) ); } return rest_ensure_response( $this->format_group( WC_EDM_DB::get_group( $id ) ) ); } /** * DELETE /groups/{id} * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function delete_group( $request ) { $id = (int) $request['id']; if ( ! WC_EDM_DB::get_group( $id ) ) { return $this->not_found(); } WC_EDM_DB::delete_group( $id ); return rest_ensure_response( array( 'deleted' => true, 'id' => $id ) ); } /* |-------------------------------------------------------------------------- | Email assignments |-------------------------------------------------------------------------- */ /** * GET /emails * * @return WP_REST_Response */ public function get_emails() { return rest_ensure_response( array_map( array( $this, 'format_email' ), WC_EDM_DB::get_emails() ) ); } /** * GET /emails/{id} * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_email( $request ) { $assignment = WC_EDM_DB::get_email( (int) $request['id'] ); return $assignment ? rest_ensure_response( $this->format_email( $assignment ) ) : $this->not_found(); } /** * Validate and normalize email assignment input. * * @param WP_REST_Request $request Request. * @param object|null $existing Existing row when updating. * @return array|WP_Error */ private function prepare_email_data( $request, $existing = null ) { $pattern = null !== $request['email_pattern'] ? strtolower( sanitize_text_field( (string) $request['email_pattern'] ) ) : ( $existing ? $existing->email_pattern : '' ); $type = null !== $request['assignment_type'] ? sanitize_key( (string) $request['assignment_type'] ) : ( $existing ? $existing->assignment_type : 'group' ); if ( '' === $pattern ) { return new WP_Error( 'wc_edm_invalid', __( 'An email pattern is required.', 'wc-email-discount' ), array( 'status' => 400 ) ); } if ( ! in_array( $type, array( 'group', 'coupon', 'custom' ), true ) ) { return new WP_Error( 'wc_edm_invalid', __( 'assignment_type must be one of: group, coupon, custom.', 'wc-email-discount' ), array( 'status' => 400 ) ); } $data = array( 'email_pattern' => $pattern, 'assignment_type' => $type, 'group_id' => null, 'coupon_code' => null, 'custom_discount' => null, 'is_active' => null !== $request['is_active'] ? ( rest_sanitize_boolean( $request['is_active'] ) ? 1 : 0 ) : ( $existing ? (int) $existing->is_active : 1 ), 'free_shipping' => null !== $request['free_shipping'] ? ( rest_sanitize_boolean( $request['free_shipping'] ) ? 1 : 0 ) : ( $existing ? (int) ! empty( $existing->free_shipping ) : 0 ), ); switch ( $type ) { case 'group': $group_id = null !== $request['group_id'] ? absint( $request['group_id'] ) : ( $existing && $existing->group_id ? (int) $existing->group_id : 0 ); if ( ! $group_id || ! WC_EDM_DB::get_group( $group_id ) ) { return new WP_Error( 'wc_edm_invalid', __( 'A valid group_id is required for group assignments.', 'wc-email-discount' ), array( 'status' => 400 ) ); } $data['group_id'] = $group_id; break; case 'coupon': $coupon = null !== $request['coupon_code'] ? wc_format_coupon_code( (string) $request['coupon_code'] ) : ( $existing ? (string) $existing->coupon_code : '' ); if ( '' === $coupon ) { return new WP_Error( 'wc_edm_invalid', __( 'A coupon_code is required for coupon assignments.', 'wc-email-discount' ), array( 'status' => 400 ) ); } $data['coupon_code'] = $coupon; break; case 'custom': $data['custom_discount'] = null !== $request['custom_discount'] ? WC_EDM_Helpers::sanitize_percent( $request['custom_discount'] ) : ( $existing && null !== $existing->custom_discount ? (float) $existing->custom_discount : 0 ); break; } return $data; } /** * POST /emails * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_email( $request ) { $data = $this->prepare_email_data( $request ); if ( is_wp_error( $data ) ) { return $data; } $id = WC_EDM_DB::insert_email( $data ); if ( ! $id ) { return new WP_Error( 'wc_edm_db_error', __( 'Could not create the assignment.', 'wc-email-discount' ), array( 'status' => 500 ) ); } return rest_ensure_response( $this->format_email( WC_EDM_DB::get_email( $id ) ) ); } /** * PUT/PATCH /emails/{id} * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_email( $request ) { $id = (int) $request['id']; $existing = WC_EDM_DB::get_email( $id ); if ( ! $existing ) { return $this->not_found(); } $data = $this->prepare_email_data( $request, $existing ); if ( is_wp_error( $data ) ) { return $data; } WC_EDM_DB::update_email( $id, $data ); return rest_ensure_response( $this->format_email( WC_EDM_DB::get_email( $id ) ) ); } /** * DELETE /emails/{id} * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function delete_email( $request ) { $id = (int) $request['id']; if ( ! WC_EDM_DB::get_email( $id ) ) { return $this->not_found(); } WC_EDM_DB::delete_email( $id ); return rest_ensure_response( array( 'deleted' => true, 'id' => $id ) ); } /* |-------------------------------------------------------------------------- | Shipping |-------------------------------------------------------------------------- */ /** * GET /shipping-methods — all shipping method instances across zones. * * @return WP_REST_Response */ public function get_shipping_methods() { return rest_ensure_response( array_values( WC_EDM_Helpers::get_all_shipping_methods() ) ); } /** * GET /groups/{id}/shipping-rules * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_shipping_rules( $request ) { $group_id = (int) $request['id']; if ( ! WC_EDM_DB::get_group( $group_id ) ) { return $this->not_found(); } $rules = array(); foreach ( WC_EDM_DB::get_shipping_rules( $group_id ) as $rule ) { $rules[] = array( 'shipping_method_id' => $rule->shipping_method_id, 'action' => $rule->action, 'sort_order' => (int) $rule->sort_order, ); } return rest_ensure_response( $rules ); } /** * PUT /groups/{id}/shipping-rules — replace all rules for the group. * * Body: { "rules": [ { "shipping_method_id": "flat_rate:3", "action": "show" }, ... ] } * Sort order follows array order. An empty rules array clears all rules. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_shipping_rules( $request ) { $group_id = (int) $request['id']; if ( ! WC_EDM_DB::get_group( $group_id ) ) { return $this->not_found(); } $raw = $request['rules']; if ( ! is_array( $raw ) ) { return new WP_Error( 'wc_edm_invalid', __( 'A rules array is required.', 'wc-email-discount' ), array( 'status' => 400 ) ); } $rules = array(); foreach ( array_values( $raw ) as $index => $rule ) { $method_id = isset( $rule['shipping_method_id'] ) ? sanitize_text_field( (string) $rule['shipping_method_id'] ) : ''; $action = isset( $rule['action'] ) ? sanitize_key( (string) $rule['action'] ) : ''; if ( '' === $method_id || ! in_array( $action, array( 'show', 'hide', 'only' ), true ) ) { return new WP_Error( 'wc_edm_invalid', __( 'Each rule needs a shipping_method_id and an action of "show", "hide" or "only".', 'wc-email-discount' ), array( 'status' => 400 ) ); } $rules[] = array( 'shipping_method_id' => $method_id, 'action' => $action, 'sort_order' => $index, ); } WC_EDM_DB::replace_shipping_rules( $group_id, $rules ); return $this->get_shipping_rules( $request ); } /* |-------------------------------------------------------------------------- | Audit log |-------------------------------------------------------------------------- */ /** * GET /audit-log — recent REST activity, newest first. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_audit_log( $request ) { $limit = min( 200, max( 1, (int) $request['limit'] ) ); $offset = max( 0, (int) $request['offset'] ); $outcome = $request['outcome'] ? sanitize_key( (string) $request['outcome'] ) : ''; $entries = array(); foreach ( WC_EDM_DB::get_audit_entries( $limit, $offset, $outcome ) as $entry ) { $entries[] = array( 'id' => (int) $entry->id, 'created_at' => $entry->created_at, 'user_id' => (int) $entry->user_id, 'user_login' => $entry->user_login, 'ip' => $entry->ip, 'method' => $entry->method, 'route' => $entry->route, 'status' => (int) $entry->status, 'outcome' => $entry->outcome, 'details' => $entry->details ? json_decode( $entry->details, true ) : null, ); } $response = rest_ensure_response( $entries ); $response->header( 'X-WP-Total', (string) WC_EDM_DB::count_audit_entries( $outcome ) ); return $response; } /* |-------------------------------------------------------------------------- | Match test |-------------------------------------------------------------------------- */ /** * GET /match?email=... — test which assignment an email would get. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function match_email( $request ) { $email = sanitize_email( (string) $request['email'] ); $match = $email ? WC_EDM_Helpers::find_email_match( $email ) : null; return rest_ensure_response( array( 'email' => $email, 'matched' => (bool) $match, 'match' => $match ? $this->format_email( $match ) : null, ) ); } }