route = 'referrals'; $this->cache_name = 'referrals'; parent::__construct(); global $wpdb; $this->wpdb = $wpdb; $this->referrals_table = $wpdb->prefix . BASE . 'referrals'; $this->rewards_table = $wpdb->prefix . BASE . 'referral_rewards'; add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); } public function registerRoutes(): void { /** * Main referrals endpoint * GET: List referrals with filters * POST: Perform actions (invite, consulted, treated, remove, resend) */ register_rest_route($this->namespace, "/{$this->route}", [ [ 'methods' => 'GET', 'callback' => [$this, 'getReferrals'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'user' => ['type' => 'integer', 'sanitize_callback' => 'absint'], 'status' => ['type' => 'string', 'enum' => ['all', 'pending', 'consulted', 'treated', 'unused', 'registered', 'completed']], 'date_start' => ['type' => 'string'], 'date_end' => ['type' => 'string'], 'limit' => ['type' => 'integer', 'default' => 50], 'offset' => ['type' => 'integer', 'default' => 0], 'format' => ['type' => 'string', 'enum' => ['simple', 'formatted'], 'default' => 'formatted'], 'search' => ['type' => 'string'] ] ], [ 'methods' => 'POST', 'callback' => [$this, 'handleAction'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'action' => [ 'required' => true, 'type' => 'string', 'enum' => ['invite', 'consulted', 'treated', 'remove', 'resend'] ] ] ] ]); /** * Referral code endpoint * GET: Get user's referral code * POST: Validate a referral code */ register_rest_route($this->namespace, "/{$this->route}/code", [ [ 'methods' => 'GET', 'callback' => [$this, 'getCode'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'user' => ['type' => 'integer', 'sanitize_callback' => 'absint'] ] ], [ 'methods' => 'POST', 'callback' => [$this, 'validateCode'], 'permission_callback' => '__return_true', // Public endpoint 'args' => [ 'code' => ['required' => true, 'type' => 'string'] ] ] ]); /** * Stats endpoint * GET: Get user's referral statistics */ register_rest_route($this->namespace, "/{$this->route}/stats", [ 'methods' => 'GET', 'callback' => [$this, 'getStats'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'user' => ['type' => 'integer', 'sanitize_callback' => 'absint'], ] ]); /** * Settings endpoint (admin only) */ register_rest_route($this->namespace, "/{$this->route}/settings", [ [ 'methods' => 'GET', 'callback' => [$this, 'getSettings'], 'permission_callback' => [$this, 'checkAdminPermission'] ], [ 'methods' => 'POST', 'callback' => [$this, 'updateSettings'], 'permission_callback' => [$this, 'checkAdminPermission'] ] ]); /** * CSV Upload endpoints (admin only) */ register_rest_route($this->namespace, "/{$this->route}/upload-clients", [ 'methods' => 'POST', 'callback' => [$this, 'handleClientUpload'], 'permission_callback' => [$this, 'checkAdminPermission'] ]); register_rest_route($this->namespace, "/{$this->route}/upload-sales", [ 'methods' => 'POST', 'callback' => [$this, 'handleSalesUpload'], 'permission_callback' => [$this, 'checkAdminPermission'] ]); } /** * GET /referrals * Get referrals with optional filters * - User gets their own referrals * - Admin with no user param gets all referrals */ public function getReferrals(WP_REST_Request $request): WP_REST_Response { $user_id = $request->get_param('user'); // Determine scope if (!$user_id) { $current_user_id = get_current_user_id(); $is_admin = current_user_can('manage_options'); if ($is_admin) { // Admin with no user param = get all referrals return $this->getAllReferrals($request); } $user_id = $current_user_id; } // Get user's referrals $args = [ 'status' => $request->get_param('status') ?? 'all', 'limit' => $request->get_param('limit') ?? 50, 'offset' => $request->get_param('offset') ?? 0, 'date_start' => $request->get_param('date_start'), 'date_end' => $request->get_param('date_end'), ]; $cache_key = "ref_{$user_id}_" . md5(serialize($args)); // Check headers for 304 Not Modified $cache_check = $this->checkHeaders($request, $cache_key); if ($cache_check instanceof WP_REST_Response) { return $cache_check; // Returns 304 if not modified } $referrals = JVB()->referrals()->getUserReferrals($user_id, $args); $data = [ 'items' => $referrals, 'total' => count($referrals) ]; // Create response with cache headers $response = $this->success($data); // Add ETag and Last-Modified headers return $this->addCacheHeaders($response, $cache_key, $data); } /** * POST /referrals * Handle various referral actions based on 'action' parameter */ public function handleAction(WP_REST_Request $request): WP_REST_Response { $action = $request->get_param('action'); return match($action) { 'invite' => $this->actionInvite($request), 'consulted' => $this->actionUpdateStatus($request, 'consulted'), 'treated' => $this->actionUpdateStatus($request, 'treated'), 'remove' => $this->actionRemove($request), 'resend' => $this->actionResend($request), default => $this->error('Invalid action', 'invalid_action', 400) }; } /** * Action: Send batch referral invitations */ protected function actionInvite(WP_REST_Request $request): WP_REST_Response { $data = $request->get_params(); error_log('Send Referral Invitations:'.print_r($data, true)); $user = absint($request->get_param('user')); if (!$this->checkUser($user)) { return new WP_REST_Response([ 'success' => false, 'message' => 'No user found' ]); } $subject = sanitize_text_field($request->get_param('subject')); $message = sanitize_textarea_field($request->get_param('message')); $invitations = $request->get_param('invite'); // Validate invitation format foreach ($invitations as $key => $invite) { if (!array_key_exists('name', $invite) || !array_key_exists('email', $invite)) { unset($invitations[$key]); } else { $temp = [ 'name' => sanitize_text_field($invite['name']), 'email' => sanitize_email($invite['email']) ]; $invitations[$key] = $temp; } } $operationID = sanitize_text_field($request->get_param('id')); $operation = JVB()->queue()->queueOperation( 'referral_invite', $user, [ 'subject' => $subject, 'message' => $message, 'invitations' => $invitations ], [ 'operation_id' => $operationID ] ); return new WP_REST_Response([ 'success' => true, 'message' => 'Queued for Processing', 'operation' => $operationID ]); } /** * Action: Update referral status (admin only) */ protected function actionUpdateStatus(WP_REST_Request $request, string $status): WP_REST_Response { if (!current_user_can('manage_options')) { return $this->error('Admin permission required', 'unauthorized', 403); } $referral_id = $request->get_param('referral_id'); if (!$referral_id) { return $this->error('referral_id required', 'missing_id', 400); } $referral = $this->wpdb->get_row($this->wpdb->prepare( "SELECT * FROM {$this->referrals_table} WHERE id = %d", $referral_id )); if (!$referral) { return $this->error('Referral not found', 'not_found', 404); } // Update status $update_data = ['status' => $status]; $update_data["{$status}_at"] = current_time('mysql'); if ($status === 'treated') { $update_data['treatment_count'] = ($referral->treatment_count ?? 0) + 1; } $updated = $this->wpdb->update( $this->referrals_table, $update_data, ['id' => $referral_id], array_fill(0, count($update_data), '%s'), ['%d'] ); if ($updated) { // Also create rewards if treated if ($status === 'treated') { $this->createRewards($referral); } } $this->cache->flush(); return $this->success(['message' => "Referral marked as {$status}"]); } /** * Action: Remove referral */ protected function actionRemove(WP_REST_Request $request): WP_REST_Response { $referral_id = $request->get_param('referral_id'); if (!$referral_id) { return $this->error('referral_id required', 'missing_id', 400); } $referral = $this->wpdb->get_row($this->wpdb->prepare( "SELECT * FROM {$this->referrals_table} WHERE id = %d", $referral_id )); if (!$referral) { return $this->error('Referral not found', 'not_found', 404); } // Check ownership $current_user_id = get_current_user_id(); if ($referral->referrer_id != $current_user_id && !current_user_can('manage_options')) { return $this->error('Unauthorized', 'unauthorized', 403); } // Can only remove pending referrals if ($referral->status !== 'pending') { return $this->error('Can only remove pending referrals', 'invalid_status', 400); } $this->wpdb->delete($this->referrals_table, ['id' => $referral_id], ['%d']); $this->cache->flush(); return $this->success(['message' => 'Referral removed']); } /** * Action: Resend invitation */ protected function actionResend(WP_REST_Request $request): WP_REST_Response { $referral_id = $request->get_param('referral_id'); if (!$referral_id) { return $this->error('referral_id required', 'missing_id', 400); } $current_user_id = get_current_user_id(); $referral = $this->wpdb->get_row($this->wpdb->prepare( "SELECT * FROM {$this->referrals_table} WHERE id = %d AND referrer_id = %d", $referral_id, $current_user_id )); if (!$referral) { return $this->error('Referral not found', 'not_found', 404); } // Check rate limit (once per week) $transient_key = 'referral_last_invite_' . md5($referral->referee_email); $last_invite = get_transient($transient_key); if ($last_invite && (time() - $last_invite) < WEEK_IN_SECONDS) { return $this->error( 'Can only resend once per week', 'rate_limit', 429 ); } // Resend via referral manager $result = JVB()->referrals()->sendReferralInvitation( $current_user_id, $referral->referee_email, $referral->referee_name, sprintf('Reminder: Join %s', get_bloginfo('name')), 'Just a friendly reminder about my invitation!' ); if (is_wp_error($result)) { return $this->error($result->get_error_message(), 'send_failed', 500); } // Set rate limit set_transient($transient_key, time(), WEEK_IN_SECONDS); return $this->success(['message' => 'Invitation resent']); } /** * GET /referrals/code * Get user's referral code */ public function getCode(WP_REST_Request $request): WP_REST_Response { $user_id = $request->get_param('user') ?? get_current_user_id(); // Check permission if ($user_id != get_current_user_id() && !current_user_can('manage_options')) { return $this->error('Unauthorized', 'unauthorized', 403); } $code = JVB()->referrals()->getUserReferralCode($user_id); if (is_wp_error($code)) { return $this->error($code->get_error_message(), 'code_error', 400); } return $this->success([ 'code' => $code, 'share_url' => home_url('/?ref=' . $code) ]); } /** * POST /referrals/code * Validate a referral code */ public function validateCode(WP_REST_Request $request): WP_REST_Response { $code = strtoupper(sanitize_text_field($request->get_param('code'))); if (empty($code)) { return $this->error('Code required', 'missing_code', 400); } $referrer = JVB()->referrals()->getUserByReferralCode($code); if (!$referrer) { return $this->error('Invalid referral code', 'invalid_code', 404); } // Check self-referral if (is_user_logged_in() && get_current_user_id() === $referrer->ID) { return $this->error('Cannot use your own referral code', 'self_referral', 400); } return $this->success([ 'valid' => true, 'code' => $code, 'referrer_name' => $referrer->display_name ]); } /** * GET /referrals/stats * Get user's referral statistics */ public function getStats(WP_REST_Request $request): WP_REST_Response { $user_id = $request->get_param('user'); $cache_key = "stats_{$user_id}"; // Check for 304 Not Modified $cache_check = $this->checkHeaders($request, $cache_key); if ($cache_check instanceof WP_REST_Response) { return $cache_check; } $stats = JVB()->referrals()->getUserStats($user_id); $response = $this->success(['items' => [$stats]]); // Add cache headers (5 minutes for stats) return $this->addCacheHeaders($response, $cache_key, $stats, 5 * MINUTE_IN_SECONDS); } /** * GET /referrals/settings */ public function getSettings(WP_REST_Request $request): WP_REST_Response { $settings = JVB()->referrals()->getRewardSettings(); return $this->success(['settings' => $settings]); } /** * POST /referrals/settings */ public function updateSettings(WP_REST_Request $request): WP_REST_Response { $settings = [ 'referrer_reward_type' => $request->get_param('referrer_reward_type') ?? 'fixed', 'referrer_reward_amount' => floatval($request->get_param('referrer_reward_amount') ?? 25), 'referrer_reward_applies_to' => $request->get_param('referrer_reward_applies_to') ?? 'per_user', 'referee_reward_type' => $request->get_param('referee_reward_type') ?? 'percentage', 'referee_reward_amount' => floatval($request->get_param('referee_reward_amount') ?? 20), 'referee_reward_applies_to' => $request->get_param('referee_reward_applies_to') ?? 'first_order' ]; update_option(BASE . 'referral_settings', $settings); $this->cache->flush(); return $this->success([ 'message' => 'Settings updated', 'settings' => $settings ]); } /** * Helper: Get all referrals (admin only) */ protected function getAllReferrals(WP_REST_Request $request): WP_REST_Response { $where = ['1=1']; $where_params = []; $status = $request->get_param('status'); if ($status && $status !== 'all') { $where[] = 'status = %s'; $where_params[] = $status; } if ($date_start = $request->get_param('date_start')) { $where[] = 'referred_at >= %s'; $where_params[] = $date_start; } if ($date_end = $request->get_param('date_end')) { $where[] = 'referred_at <= %s'; $where_params[] = $date_end; } $search = $request->get_param('search'); if (!empty($search)) { $search_term = '%' . $this->wpdb->esc_like($search) . '%'; $where[] = '(r.referee_name LIKE %s OR r.referee_email LIKE %s OR r.referral_code LIKE %s OR u.display_name LIKE %s OR ru.display_name LIKE %s OR ru.user_email LIKE %s)'; $where_params[] = $search_term; $where_params[] = $search_term; $where_params[] = $search_term; $where_params[] = $search_term; $where_params[] = $search_term; $where_params[] = $search_term; } $limit = $request->get_param('limit') ?? 50; $offset = $request->get_param('offset') ?? 0; $where_params[] = $limit; $where_params[] = $offset; $query = "SELECT r.*, u.display_name as referrer_name FROM {$this->referrals_table} r LEFT JOIN {$this->wpdb->users} u ON r.referrer_id = u.ID WHERE " . implode(' AND ', $where) . " ORDER BY referred_at DESC LIMIT %d OFFSET %d"; $items = $this->wpdb->get_results($this->wpdb->prepare($query, $where_params)); error_log('All Referrals result: '.print_r($items, true)); return $this->success([ 'items' => $items, 'total' => count($items) ]); } /** * Helper: Create rewards for completed referral */ protected function createRewards(object $referral): void { $settings = JVB()->referrals()->getRewardSettings(); // Referrer reward $this->wpdb->insert( $this->rewards_table, [ 'referral_id' => $referral->id, 'user_id' => $referral->referrer_id, 'reward_type' => 'referrer', 'amount' => $settings['referrer_reward_amount'], 'reward_calculation' => $settings['referrer_reward_type'], 'status' => 'available', 'created_at' => current_time('mysql') ], ['%d', '%d', '%s', '%f', '%s', '%s', '%s'] ); // Referee reward if ($referral->referee_id) { $this->wpdb->insert( $this->rewards_table, [ 'referral_id' => $referral->id, 'user_id' => $referral->referee_id, 'reward_type' => 'referee', 'amount' => $settings['referee_reward_amount'], 'reward_calculation' => $settings['referee_reward_type'], 'status' => 'available', 'created_at' => current_time('mysql') ], ['%d', '%d', '%s', '%f', '%s', '%s', '%s'] ); } } /** * Check admin permission */ public function checkAdminPermission(WP_REST_Request $request): bool { return current_user_can('manage_options') && parent::checkPermission($request); } /** * Process queued referral operations */ public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error { if ($operation->type !== 'referral_invite') { return $result; } $result = JVB()->referrals()->sendBatchReferralInvitations( $operation->user_id, $data['invitations'], $data['subject'], $data['message'] ); if ($result['success']) { $this->cache->flush(); } // Build summary message $textResult = 'Sent invitations. '; $textResult .= 'Success: ' . count($result['result']['success']) . '. '; $textResult .= 'Failed: ' . count($result['result']['failed']) . '.'; return [ 'success' => true, 'message' => $textResult, 'details' => [ 'successful' => $result['result']['success'], 'failed' => $result['result']['failed'], 'total' => count($data['invitations']) ] ]; } /** * Handle client CSV upload */ public function handleClientUpload(WP_REST_Request $request): WP_REST_Response { // Access files from $_FILES directly for REST API uploads if (empty($_FILES['file'])) { return new WP_REST_Response([ 'success' => false, 'message' => 'No file uploaded' ], 400); } $file = $_FILES['file']; // Check for upload errors if ($file['error'] !== UPLOAD_ERR_OK) { return new WP_REST_Response([ 'success' => false, 'message' => 'File upload error: ' . $file['error'] ], 400); } // Validate file type $allowed_types = ['text/csv', 'application/vnd.ms-excel', 'text/plain']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!in_array($mime_type, $allowed_types) && !in_array($file['type'], $allowed_types)) { return new WP_REST_Response([ 'success' => false, 'message' => 'File must be a CSV' ], 400); } // Validate file size (10MB max) if ($file['size'] > 10 * 1024 * 1024) { return new WP_REST_Response([ 'success' => false, 'message' => 'File size exceeds 10MB limit' ], 400); } // Import using JaneAppClientImporter $importer = new JaneAppClientImporter(); $default_role = get_option(BASE . 'referral_role', JVB_USER); $options = [ 'update_existing' => true, 'send_welcome_email' => false, 'create_users' => true, 'default_role' => $default_role ]; $result = $importer->importFromCSV($file['tmp_name'], $options); if (is_wp_error($result)) { return new WP_REST_Response([ 'success' => false, 'message' => $result->get_error_message() ], 500); } // Build detailed message $message = sprintf( 'Import complete: %d created, %d updated, %d skipped', $result['created'], $result['updated'], $result['skipped'] ); $details = []; if (!empty($result['skipped_details'])) { $details = $result['skipped_details']; } // Clear cache $this->cache->flush(); return new WP_REST_Response([ 'success' => true, 'message' => $message, 'stats' => $result, 'skipped_details' => $details ]); } /** * Handle sales CSV upload */ public function handleSalesUpload(WP_REST_Request $request): WP_REST_Response { // Access files from $_FILES directly for REST API uploads if (empty($_FILES['file'])) { return new WP_REST_Response([ 'success' => false, 'message' => 'No file uploaded' ], 400); } $file = $_FILES['file']; // Check for upload errors if ($file['error'] !== UPLOAD_ERR_OK) { return new WP_REST_Response([ 'success' => false, 'message' => 'File upload error: ' . $file['error'] ], 400); } // Validate file type $allowed_types = ['text/csv', 'application/vnd.ms-excel', 'text/plain']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!in_array($mime_type, $allowed_types) && !in_array($file['type'], $allowed_types)) { return new WP_REST_Response([ 'success' => false, 'message' => 'File must be a CSV' ], 400); } // Validate file size (10MB max) if ($file['size'] > 10 * 1024 * 1024) { return new WP_REST_Response([ 'success' => false, 'message' => 'File size exceeds 10MB limit' ], 400); } // Import using JaneSalesImporter $importer = new JaneSalesImporter(); $options = [ 'skip_existing' => true ]; $result = $importer->importFromCSV($file['tmp_name'], $options); if (is_wp_error($result)) { return new WP_REST_Response([ 'success' => false, 'message' => $result->get_error_message() ], 500); } // Clear cache $this->cache->flush(); return new WP_REST_Response([ 'success' => true, 'message' => 'Sales imported successfully', 'stats' => $result ]); } }