cacheName = 'referrals'; $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); } public function registerRoutes(): void { // Main referrals endpoint - list and manage referrals Route::for('referrals') ->get([$this, 'getReferrals']) ->args([ 'user' => 'integer', 'status' => 'string|enum:all,pending,consulted,treated,unused,registered,completed|default:all', 'date_start' => 'string', 'date_end' => 'string', 'limit' => 'integer|default:50', 'offset' => 'integer|default:0', 'search' => 'string' ]) ->rateLimit() ->post([$this, 'handleAction']) ->args([ 'action' => 'string|required|enum:invite,consulted,treated,remove,resend' ]) ->auth('user') ->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) ->register(); // Stats endpoint Route::for('referrals/stats') ->get([$this, 'getStats']) ->args(['user' => 'integer']) ->auth('user') ->rateLimit(30) ->register(); // Settings endpoint (admin only) Route::for('referrals/settings') ->get([$this, 'getSettings']) ->post([$this, 'updateSettings']) ->auth('admin') ->rateLimit(10) ->register(); // CSV Upload endpoints (admin only) Route::for('referrals/upload-clients') ->post([$this, 'handleClientUpload']) ->auth('admin') ->rateLimit(3) ->register(); Route::for('referrals/upload-sales') ->post([$this, 'handleSalesUpload']) ->auth('admin') ->rateLimit(3) ->register(); } /** * 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: 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; } // 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; } // Get referrals from manager $referrals = JVB()->referrals()->getUserReferrals($user_id, $args); $data = [ 'items' => $referrals, 'total' => count($referrals) ]; $response = $this->success($data); return $this->addCacheHeaders($response); } /** * 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 { $user = absint($request->get_param('user')); if (!$user || !get_userdata($user)) { return $this->error('Invalid user', 'invalid_user', 400); } //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')); $invitations = $request->get_param('invite'); // Validate and sanitize invitations $sanitized_invitations = []; foreach ($invitations as $invite) { if (isset($invite['name'], $invite['email'])) { $sanitized_invitations[] = [ 'name' => sanitize_text_field($invite['name']), 'email' => sanitize_email($invite['email']) ]; } } if (empty($sanitized_invitations)) { return $this->error('No valid invitations provided', 'no_invitations', 400); } $operationID = sanitize_text_field($request->get_param('id')); JVB()->queue()->queueOperation( 'referral_invite', $user, [ 'subject' => $subject, 'message' => $message, 'invitations' => $sanitized_invitations ], ['operation_id' => $operationID] ); return $this->queued($operationID, 'Referral invitations queued'); } /** * 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->forbidden('Admin permission required'); } $referral_id = $request->get_param('referral_id'); if (!$referral_id) { return $this->error('referral_id required', 'missing_id', 400); } // Get referral using CustomTable $referral = $this->referrals->get(['id' => $referral_id]); if (!$referral) { return $this->notFound('Referral not found'); } // 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); } /** * 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); } // Get referral $referral = $this->referrals->get(['id' => $referral_id]); if (!$referral) { return $this->notFound('Referral not found'); } // 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'); } // 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']); } /** * 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(); // 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'); } // 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']); } /** * 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->forbidden('Unauthorized'); } $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') ?? 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; } $stats = JVB()->referrals()->getUserStats($user_id); $response = $this->success(['items' => [$stats]]); return $this->addCacheHeaders($response); } /** * 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 { global $wpdb; $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) ]); } /** * 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 */ public function handleClientUpload(WP_REST_Request $request): WP_REST_Response { if (empty($_FILES['file'])) { return $this->error('No file uploaded', 'no_file', 400); } $file = $_FILES['file']; if ($file['error'] !== UPLOAD_ERR_OK) { return $this->error('File upload error: ' . $file['error'], 'upload_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 $this->error('File must be a CSV', 'invalid_file_type', 400); } // Validate file size (10MB max) if ($file['size'] > 10 * 1024 * 1024) { return $this->error('File size exceeds 10MB limit', 'file_too_large', 400); } // Import using JaneAppClientImporter $importer = new JaneAppClientImporter(); $default_role = get_option(BASE . 'referral_role', Site::getDefaultReferralRole()); $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 $this->error($result->get_error_message(), 'import_failed', 500); } $this->cache->flush(); return $this->success([ 'message' => sprintf( 'Import complete: %d created, %d updated, %d skipped', $result['created'], $result['updated'], $result['skipped'] ), 'stats' => $result, 'skipped_details' => $result['skipped_details'] ?? [] ]); } /** * Handle sales CSV upload */ public function handleSalesUpload(WP_REST_Request $request): WP_REST_Response { if (empty($_FILES['file'])) { return $this->error('No file uploaded', 'no_file', 400); } $file = $_FILES['file']; if ($file['error'] !== UPLOAD_ERR_OK) { return $this->error('File upload error: ' . $file['error'], 'upload_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 $this->error('File must be a CSV', 'invalid_file_type', 400); } // Validate file size (10MB max) if ($file['size'] > 10 * 1024 * 1024) { return $this->error('File size exceeds 10MB limit', 'file_too_large', 400); } // Import using JaneAppSalesImporter $importer = new JaneAppSalesImporter(); $result = $importer->importFromCSV($file['tmp_name'], ['skip_existing' => true]); if (is_wp_error($result)) { return $this->error($result->get_error_message(), 'import_failed', 500); } $this->cache->flush(); return $this->success([ 'message' => 'Sales imported successfully', 'stats' => $result ]); } }