'GET', 'callback' => [$this, 'index'], 'permission_callback' => [Routes::class, 'canManage'], 'args' => self::queryArgs(false), ], ]); // Contact details and free-text searches travel in a nonce-protected // request body, never in an access-log or browser-history URL. register_rest_route(Routes::NAMESPACE, '/leads/query', [ [ 'methods' => 'POST', 'callback' => [$this, 'index'], 'permission_callback' => [Routes::class, 'canManage'], 'args' => self::queryArgs(true), ], ]); register_rest_route(Routes::NAMESPACE, '/leads/retention', [ [ 'methods' => 'GET', 'callback' => [$this, 'showRetention'], 'permission_callback' => [Routes::class, 'canManage'], ], [ 'methods' => 'POST', 'callback' => [$this, 'updateRetention'], 'permission_callback' => [Routes::class, 'canManage'], 'args' => [ // Nullable on purpose: null is keep-forever, which is what // clearing the field means and what WConvert ships as // (ADR 0018). 'days' => ['type' => ['integer', 'null']], ], ], ]); register_rest_route(Routes::NAMESPACE, '/leads/identifier', [ [ 'methods' => 'DELETE', 'callback' => [$this, 'eraseIdentifier'], 'permission_callback' => [Routes::class, 'canManage'], 'args' => [ 'identifier' => ['type' => 'string', 'required' => true, 'maxLength' => 254], 'confirmed_identifier' => ['type' => 'string', 'required' => true, 'maxLength' => 254], ], ], ]); } /** * @return array> */ private static function queryArgs(bool $personal): array { $args = [ // A ULID or nothing. An `optin_id` that is not one is a filter // that would silently match no rows, which reads as an empty log. 'optin_id' => [ 'type' => 'string', 'pattern' => '^' . Ulid::PATTERN . '$', ], 'grouped' => ['type' => 'boolean', 'default' => false], 'purpose' => ['type' => 'string', 'enum' => ['subscribers', 'enquiries']], 'order' => ['type' => 'string', 'enum' => ['newest', 'oldest']], 'include_counts' => ['type' => 'boolean', 'default' => false], 'from' => ['type' => 'string', 'maxLength' => 10], 'to' => ['type' => 'string', 'maxLength' => 10], 'cursor' => ['type' => 'string', 'maxLength' => 128], 'snapshot' => ['type' => 'string', 'pattern' => '^' . Ulid::PATTERN . '$'], 'per_page' => [ 'type' => 'integer', 'default' => self::DEFAULT_PER_PAGE, 'minimum' => 1, 'maximum' => LeadLog::MAX_ROWS, ], ]; if ($personal) { $args['lead_id'] = ['type' => 'string', 'pattern' => '^' . Ulid::PATTERN . '$']; $args['identifier'] = ['type' => 'string', 'maxLength' => 254]; $args['search'] = ['type' => 'string', 'maxLength' => 200]; $args['group_identifier'] = ['type' => 'string', 'maxLength' => 254]; } return $args; } public function index(WP_REST_Request $request): WP_REST_Response|WP_Error { if ($request->get_method() === 'GET') { foreach (['lead_id', 'identifier', 'search', 'group_identifier'] as $personal) { if ($request->get_param($personal) !== null) { return new WP_Error( 'wconvert_personal_query_in_url', __('Search submissions from the WConvert admin screen.', 'wconvert'), ['status' => 400] ); } } } try { $query = LeadQuery::fromInput($request->get_params()); } catch (InvalidArgumentException $invalid) { return new WP_Error('wconvert_invalid_lead_query', $invalid->getMessage(), ['status' => 400]); } $payload = $this->log->read( $query->optinId, (bool) $request->get_param('grouped'), (int) ($request->get_param('per_page') ?? self::DEFAULT_PER_PAGE), $query ); if ($request->get_param('include_counts')) $payload['purpose_counts'] = $this->log->counts($query, $payload['submissions']); return new WP_REST_Response($payload); } public function showRetention(): WP_REST_Response { return new WP_REST_Response($this->retentionState()); } public function updateRetention(WP_REST_Request $request): WP_REST_Response { $days = $request->get_param('days'); $this->retention->set($days === null ? null : (int) $days); return new WP_REST_Response($this->retentionState()); } public function eraseIdentifier(WP_REST_Request $request): WP_REST_Response|WP_Error { $identifier = (string) $request->get_param('identifier'); $confirmed = (string) $request->get_param('confirmed_identifier'); $canonical = LeadErasure::canonical($identifier); $confirmation = LeadErasure::canonical($confirmed); // Never let a missing/mismatched confirmation fall through to a // broader action. Both values are canonicalised before anything is // deleted, so spelling differences are harmless but a different // person cannot be confirmed accidentally. if ($canonical === null || $confirmation === null || $canonical !== $confirmation) { return new WP_Error( 'wconvert_invalid_erasure_identifier', __('Enter and confirm one complete email address or phone number with its country code.', 'wconvert'), ['status' => 400] ); } return new WP_REST_Response($this->erasure->erase($canonical)); } /** * @return array{days: int|null, max_days: int} */ private function retentionState(): array { return ['days' => $this->retention->days(), 'max_days' => RetentionPeriod::MAX_DAYS]; } }