Jake Vanderwerf
2026-08-28 a9654f00c8fbc9934289d761843ddd071025013f
inc/rest/routes/ReferralRoutes.php
@@ -1,14 +1,17 @@
<?php
namespace JVBase\rest\routes;
use JVBase\base\Site;
use JVBase\importers\JaneAppClientImporter;
use JVBase\importers\JaneAppSalesImporter;
use JVBase\managers\CustomTable;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\queue\Queue;
use JVBase\managers\Referrals;
use JVBase\rest\Rest;
use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
if (!defined('ABSPATH')) {
   exit;
@@ -19,21 +22,13 @@
 */
class ReferralRoutes extends Rest
{
   protected CustomTable $referrals;
   protected CustomTable $rewards;
   protected CustomTable $treatments;
   use Instance;
   public function __construct()
   {
      $this->cacheName = 'referrals';
      $this->cacheTtl = HOUR_IN_SECONDS;
      $this->cacheTtl = (int)HOUR_IN_SECONDS;
      parent::__construct();
      $this->referrals = CustomTable::for('referrals');
      $this->rewards = CustomTable::for('referral_rewards');
      $this->treatments = CustomTable::for('referral_treatments');
      add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
      $this->cache = Cache::for($this->cacheName, 'custom', $this->cacheTtl)->user();
   }
   public function registerRoutes(): void
@@ -42,8 +37,7 @@
      Route::for('referrals')
         ->get([$this, 'getReferrals'])
         ->args([
            'user' => 'integer',
            'status' => 'string|enum:all,pending,consulted,treated,unused,registered,completed|default:all',
            'status' => 'string|enum:all,invited,pending,consulted,treated,cancelled|default:all',
            'date_start' => 'string',
            'date_end' => 'string',
            'limit' => 'integer|default:50',
@@ -56,43 +50,47 @@
            'action' => 'string|required|enum:invite,consulted,treated,remove,resend'
         ])
         ->auth('user')
         ->rateLimit(10);
         ->rateLimit(10)
         ->register();
      // Referral code endpoint
      Route::for('referrals/code')
         ->get([$this, 'getCode'])
         ->args(['user' => 'integer'])
         ->auth('user')
         ->rateLimit(30)
         ->post([$this, 'validateCode'])
         ->args(['code' => 'string|required'])
         ->auth('public')
         ->rateLimit(10);
         ->rateLimit(10)
         ->register();
      // Stats endpoint
      Route::for('referrals/stats')
         ->get([$this, 'getStats'])
         ->args(['user' => 'integer'])
         ->auth('user')
         ->rateLimit(30);
         ->rateLimit(30)
         ->register();
      // Settings endpoint (admin only)
      Route::for('referrals/settings')
         ->get([$this, 'getSettings'])
         ->post([$this, 'updateSettings'])
         ->auth('admin')
         ->rateLimit(10);
         ->rateLimit(10)
         ->register();
      // CSV Upload endpoints (admin only)
      Route::for('referrals/upload-clients')
         ->post([$this, 'handleClientUpload'])
         ->auth('admin')
         ->rateLimit(3);
         ->rateLimit(3)
         ->register();
      Route::for('referrals/upload-sales')
         ->post([$this, 'handleSalesUpload'])
         ->auth('admin')
         ->rateLimit(3);
         ->rateLimit(3)
         ->register();
   }
   /**
@@ -103,43 +101,23 @@
    */
   public function getReferrals(WP_REST_Request $request): WP_REST_Response
   {
      $user_id = $request->get_param('user');
      $user_id = get_current_user_id();
      // Determine scope: admin without user param gets all referrals
      if (!$user_id) {
         $current_user_id = get_current_user_id();
         if (current_user_can('manage_options')) {
            return $this->getAllReferrals($request);
         }
         $user_id = $current_user_id;
         return $this->unauthorized();
      }
      // Build cache key
      $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 = "user_{$user_id}_" . $this->cache->generateKey($args);
      // Check 304 Not Modified
      $cache_check = $this->checkHeaders($request, $cache_key);
      if ($cache_check instanceof WP_REST_Response) {
         return $cache_check;
      if (current_user_can('manage_options')) {
         return $this->getAllReferrals($request);
      }
      $args = $this->buildArgs($request);
      // Get referrals from manager
      $referrals = JVB()->referrals()->getUserReferrals($user_id, $args);
      $referrals = Referrals::getInstance()->getUserReferrals($user_id, $args);
      $data = [
         'items' => $referrals,
         'total' => count($referrals)
      ];
      $response = $this->success($data);
      return $this->addCacheHeaders($response, $cache_key, $data);
      $response = $this->success($referrals);
      return $this->addCacheHeaders($response);
   }
   /**
@@ -156,7 +134,7 @@
         'treated' => $this->actionUpdateStatus($request, 'treated'),
         'remove' => $this->actionRemove($request),
         'resend' => $this->actionResend($request),
         default => $this->error('Invalid action', 'invalid_action', 400)
         default => $this->error('Invalid action', 'invalid_action')
      };
   }
@@ -165,23 +143,13 @@
    */
   protected function actionInvite(WP_REST_Request $request): WP_REST_Response
   {
      $user = absint($request->get_param('user'));
      if (!$user || !get_userdata($user)) {
         return $this->error('Invalid user', 'invalid_user', 400);
      $user = get_current_user_id();
      if (!$user) {
         return $this->unauthorized();
      }
      //Additional check to not send too many emails in an hour
      $user = absint($request->get_param('user'));
      $transient_key = "referral_invite_limit_{$user}";
      $recent_invites = get_transient($transient_key) ?: 0;
      if ($recent_invites >= 20) { // Max 5 batch invites per hour
         return $this->error('Too many invitations sent. Please try again later.', 'rate_limit', 429);
      }
      set_transient($transient_key, $recent_invites + 1, HOUR_IN_SECONDS);
      $subject = sanitize_text_field($request->get_param('subject'));
      $message = sanitize_textarea_field($request->get_param('message'));
      $subject = sanitize_text_field($request->get_param('subject')??'');
      $message = sanitize_textarea_field($request->get_param('message')??'');
      $invitations = $request->get_param('invite');
      // Validate and sanitize invitations
@@ -196,11 +164,11 @@
      }
      if (empty($sanitized_invitations)) {
         return $this->error('No valid invitations provided', 'no_invitations', 400);
         return $this->error('No valid invitations provided', 'no_invitations',);
      }
      $operationID = sanitize_text_field($request->get_param('id'));
      JVB()->queue()->queueOperation(
      Queue::getInstance()->queueOperation(
         'referral_invite',
         $user,
         [
@@ -225,36 +193,19 @@
      $referral_id = $request->get_param('referral_id');
      if (!$referral_id) {
         return $this->error('referral_id required', 'missing_id', 400);
         return $this->error('referral_id required', 'missing_id');
      }
      // Get referral using CustomTable
      $referral = $this->referrals->get(['id' => $referral_id]);
      if (!$referral) {
         return $this->notFound('Referral not found');
      [$success, $message] = Referrals::getInstance()->updateStatus($referral_id, $status);
      if (!$success) {
         return $message === 'Referral not found'
            ? $this->notFound($message)
            : $this->error($message);
      }
      // 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->referrals->update($update_data, ['id' => $referral_id]);
      if ($updated !== false) {
         // Create rewards if treated
         if ($status === 'treated') {
            $this->createRewards($referral);
         }
         $this->cache->flush();
         return $this->success(['message' => "Referral marked as {$status}"]);
      }
      return $this->error('Failed to update referral', 'update_failed', 500);
      $this->cache->flush();
      return $this->success(['message' => "Referral marked as {$status}"]);
   }
   /**
@@ -264,29 +215,15 @@
   {
      $referral_id = $request->get_param('referral_id');
      if (!$referral_id) {
         return $this->error('referral_id required', 'missing_id', 400);
         return $this->error('referral_id required', 'missing_id');
      }
      // Get referral
      $referral = $this->referrals->get(['id' => $referral_id]);
      if (!$referral) {
         return $this->notFound('Referral not found');
      }
      $result = Referrals::getInstance()->removeReferral($referral_id, get_current_user_id());
      // Check ownership
      $current_user_id = get_current_user_id();
      if ($referral->referrer_id != $current_user_id && !current_user_can('manage_options')) {
         return $this->forbidden('Unauthorized');
      if (!$result['success']) {
         return $this->error($result['message']);
      }
      // Can only remove pending referrals
      if ($referral->status !== 'pending') {
         return $this->error('Can only remove pending referrals', 'invalid_status', 400);
      }
      $this->referrals->delete(['id' => $referral_id]);
      $this->cache->flush();
      return $this->success(['message' => 'Referral removed']);
   }
@@ -297,47 +234,15 @@
   {
      $referral_id = $request->get_param('referral_id');
      if (!$referral_id) {
         return $this->error('referral_id required', 'missing_id', 400);
         return $this->error('referral_id required', 'missing_id');
      }
      $current_user_id = get_current_user_id();
      $result = Referrals::getInstance()->resendInvitation($referral_id, get_current_user_id());
      // Get referral with ownership check
      $referral = $this->referrals->where([
         'id' => $referral_id,
         'referrer_id' => $current_user_id
      ])->first();
      if (!$referral) {
         return $this->notFound('Referral not found');
      if (!$result['success']) {
         return $this->error($result['message']);
      }
      // 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_transient($transient_key, time(), WEEK_IN_SECONDS);
      return $this->success(['message' => 'Invitation resent']);
   }
@@ -347,17 +252,16 @@
    */
   public function getCode(WP_REST_Request $request): WP_REST_Response
   {
      $user_id = $request->get_param('user') ?? get_current_user_id();
      $user_id = get_current_user_id();
      // Check permission
      if ($user_id != get_current_user_id() && !current_user_can('manage_options')) {
         return $this->forbidden('Unauthorized');
      if (!$user_id) {
         return $this->unauthorized();
      }
      $code = JVB()->referrals()->getUserReferralCode($user_id);
      $code = Referrals::getInstance()->getUserReferralCode($user_id);
      if (is_wp_error($code)) {
         return $this->error($code->get_error_message(), 'code_error', 400);
         return $this->error($code->get_error_message(), 'code_error');
      }
      return $this->success([
@@ -372,13 +276,13 @@
    */
   public function validateCode(WP_REST_Request $request): WP_REST_Response
   {
      $code = strtoupper(sanitize_text_field($request->get_param('code')));
      $code = strtoupper(sanitize_text_field($request->get_param('code')??''));
      if (empty($code)) {
         return $this->error('Code required', 'missing_code', 400);
         return $this->error('Code required', 'missing_code');
      }
      $referrer = JVB()->referrals()->getUserByReferralCode($code);
      $referrer = Referrals::getInstance()->getUserByReferralCode($code);
      if (!$referrer) {
         return $this->error('Invalid referral code', 'invalid_code', 404);
@@ -386,7 +290,7 @@
      // 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->error('Cannot use your own referral code', 'self_referral');
      }
      return $this->success([
@@ -402,19 +306,14 @@
    */
   public function getStats(WP_REST_Request $request): WP_REST_Response
   {
      $user_id = $request->get_param('user') ?? get_current_user_id();
      $cache_key = "stats_{$user_id}";
      // Check 304 Not Modified
      $cache_check = $this->checkHeaders($request, $cache_key);
      if ($cache_check instanceof WP_REST_Response) {
         return $cache_check;
      $user_id = get_current_user_id();
      if (!$user_id) {
         return $this->unauthorized();
      }
      $stats = JVB()->referrals()->getUserStats($user_id);
      $stats = Referrals::getInstance()->getUserStats($user_id);
      $response = $this->success(['items' => [$stats]]);
      return $this->addCacheHeaders($response, $cache_key, $stats, 5 * MINUTE_IN_SECONDS);
      return $this->success(['items' => [$stats]]);
   }
   /**
@@ -422,7 +321,7 @@
    */
   public function getSettings(WP_REST_Request $request): WP_REST_Response
   {
      $settings = JVB()->referrals()->getRewardSettings();
      $settings = Referrals::getInstance()->getRewardSettings();
      return $this->success(['settings' => $settings]);
   }
@@ -452,126 +351,32 @@
   /**
    * Helper: Get all referrals (admin only)
    */
   protected function buildArgs(WP_REST_Request $request):array
   {
      $params = $request->get_params();
      $args = [
         'limit'     => absint($params['limit']??20),
         'offset' => absint($params['offset']??0)
      ];
      if (array_key_exists('status', $params) && $params['status'] !== 'all') {
         $args['status'] = sanitize_text_field($params['status']);
      }
      if (array_key_exists('search', $params)) {
         $args['search'] = sanitize_text_field($params['search']);
      }
      if (array_key_exists('date_start', $params) && array_key_exists('date_end', $params)) {
         $args['created_at'] = ['BETWEEN', [sanitize_text_field($params['date_start']), sanitize_text_field($params['date_end'])]];
      }
      return $args;
   }
   protected function getAllReferrals(WP_REST_Request $request): WP_REST_Response
   {
      global $wpdb;
      $args = $this->buildArgs($request);
      $items = Referrals::getInstance()->getReferrals($args);
      $where = ['1=1'];
      $where_params = [];
      // Build WHERE conditions
      $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 = '%' . $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)';
         $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;
      // Use CustomTable's query method
      $query = "SELECT r.*, u.display_name as referrer_name
         FROM {table} r
         LEFT JOIN {$wpdb->users} u ON r.referrer_id = u.ID
         WHERE " . implode(' AND ', $where) . "
         ORDER BY referred_at DESC
         LIMIT %d OFFSET %d";
      $items = $this->referrals->queryResults($query, $where_params);
      return $this->success([
         'items' => $items,
         'total' => count($items)
      ]);
      return $this->success($items);
   }
   /**
    * Helper: Create rewards for completed referral
    */
   protected function createRewards(object $referral): void
   {
      $settings = JVB()->referrals()->getRewardSettings();
      // Referrer reward
      $this->rewards->insert([
         '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'
      ]);
      // Referee reward
      if ($referral->referee_id) {
         $this->rewards->insert([
            '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'
         ]);
      }
   }
   /**
    * 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();
      }
      return [
         'success' => true,
         'message' => sprintf(
            'Sent invitations. Success: %d. Failed: %d.',
            count($result['result']['success']),
            count($result['result']['failed'])
         ),
         'details' => [
            'successful' => $result['result']['success'],
            'failed' => $result['result']['failed'],
            'total' => count($data['invitations'])
         ]
      ];
   }
   /**
    * Handle client CSV upload
@@ -579,13 +384,13 @@
   public function handleClientUpload(WP_REST_Request $request): WP_REST_Response
   {
      if (empty($_FILES['file'])) {
         return $this->error('No file uploaded', 'no_file', 400);
         return $this->error('No file uploaded', 'no_file');
      }
      $file = $_FILES['file'];
      if ($file['error'] !== UPLOAD_ERR_OK) {
         return $this->error('File upload error: ' . $file['error'], 'upload_error', 400);
         return $this->error('File upload error: ' . $file['error'], 'upload_error');
      }
      // Validate file type
@@ -595,17 +400,17 @@
      finfo_close($finfo);
      if (!in_array($mime_type, $allowed_types) && !in_array($file['type'], $allowed_types)) {
         return $this->error('File must be a CSV', 'invalid_file_type', 400);
         return $this->error('File must be a CSV', 'invalid_file_type');
      }
      // Validate file size (10MB max)
      if ($file['size'] > 10 * 1024 * 1024) {
         return $this->error('File size exceeds 10MB limit', 'file_too_large', 400);
         return $this->error('File size exceeds 10MB limit', 'file_too_large');
      }
      // Import using JaneAppClientImporter
      $importer = new JaneAppClientImporter();
      $default_role = get_option(BASE . 'referral_role', JVB_USER);
      $default_role = get_option(BASE . 'referral_role', Site::getReferralRole());
      $options = [
         'update_existing' => true,
@@ -640,13 +445,13 @@
   public function handleSalesUpload(WP_REST_Request $request): WP_REST_Response
   {
      if (empty($_FILES['file'])) {
         return $this->error('No file uploaded', 'no_file', 400);
         return $this->error('No file uploaded', 'no_file');
      }
      $file = $_FILES['file'];
      if ($file['error'] !== UPLOAD_ERR_OK) {
         return $this->error('File upload error: ' . $file['error'], 'upload_error', 400);
         return $this->error('File upload error: ' . $file['error'], 'upload_error');
      }
      // Validate file type
@@ -656,12 +461,12 @@
      finfo_close($finfo);
      if (!in_array($mime_type, $allowed_types) && !in_array($file['type'], $allowed_types)) {
         return $this->error('File must be a CSV', 'invalid_file_type', 400);
         return $this->error('File must be a CSV', 'invalid_file_type');
      }
      // Validate file size (10MB max)
      if ($file['size'] > 10 * 1024 * 1024) {
         return $this->error('File size exceeds 10MB limit', 'file_too_large', 400);
         return $this->error('File size exceeds 10MB limit', 'file_too_large');
      }
      // Import using JaneAppSalesImporter