false, 'message' => 'Invalid request method']); exit; } try { $pdo = getClientDBConnection(); $panelPdo = getPanelDBConnection(); $selection_id = isset($_POST['selection_id']) ? (int)$_POST['selection_id'] : 0; $incentive_override = isset($_POST['incentive_amount']) ? (float)$_POST['incentive_amount'] : 0; $dispatch_type = isset($_POST['dispatch_type']) ? $_POST['dispatch_type'] : 'first'; if (!$selection_id) { throw new Exception('Invalid selection ID'); } // 1. Get selection + project details $stmt = $pdo->prepare(" SELECT ps.*, p.project_name, p.project_id AS project_code, p.eloi, p.id AS proj_id, p.status AS project_status, p.made_live_at FROM project_selections ps JOIN projects p ON ps.project_id = p.id WHERE ps.id = ? AND ps.client_id = ? "); $stmt->execute([$selection_id, $_SESSION['client_id']]); $selection = $stmt->fetch(PDO::FETCH_ASSOC); if (!$selection) { throw new Exception('Selection not found or unauthorized'); } $eloi = (int)$selection['eloi']; $default_incentive = calculateDefaultIncentive($eloi); $dispatch_count = (int)($selection['mail_dispatch_count'] ?? 0); // ====================================================== // RESEND FLOW // ====================================================== if ($dispatch_type === 'resend') { // Must be live or hold (invitations already sent) if (!in_array($selection['status'], ['live', 'hold'])) { throw new Exception('Selection must be live or on hold to resend invitations. Current status: ' . $selection['status']); } if (empty($selection['invitations_sent_at'])) { throw new Exception('First invitations have not been sent yet.'); } // Check dispatch count limits if ($dispatch_count >= 3) { throw new Exception('Maximum of 3 dispatches (1 original + 2 reminders) have already been sent for this selection.'); } $now = time(); $first_sent_at = strtotime($selection['invitations_sent_at']); $second_sent_at = !empty($selection['second_dispatch_at']) ? strtotime($selection['second_dispatch_at']) : null; // Validate time gaps if ($dispatch_count == 1) { // 2nd dispatch: must be at least 24 hours after 1st $min_time = $first_sent_at + (24 * 3600); if ($now < $min_time) { $remaining = $min_time - $now; $hours = floor($remaining / 3600); $mins = ceil(($remaining % 3600) / 60); throw new Exception("Cannot send 2nd dispatch yet. Must wait at least 24 hours after the first send. Time remaining: {$hours}h {$mins}m."); } } elseif ($dispatch_count == 2) { // 3rd dispatch: must be at least 48 hours after 2nd $min_time = $second_sent_at + (48 * 3600); if ($now < $min_time) { $remaining = $min_time - $now; $hours = floor($remaining / 3600); $mins = ceil(($remaining % 3600) / 60); throw new Exception("Cannot send 3rd dispatch yet. Must wait at least 48 hours after the second send. Time remaining: {$hours}h {$mins}m."); } } $new_dispatch_number = $dispatch_count + 1; // Get URLs that are still in 'sent' status (not taken at all) // "Taken" = any status beyond 'sent': clicked, complete, partial, screenout, quotafull, timeout $stmt = $pdo->prepare(" SELECT id, rr_proxy_url, unique_identifier, sent_to_user_id FROM survey_urls WHERE assigned_to_selection_id = ? AND status = 'sent' ORDER BY id ASC "); $stmt->execute([$selection_id]); $pending_urls = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($pending_urls)) { throw new Exception('No pending recipients found. All members have already taken the survey or their URLs have a terminal status.'); } // Get emails from panel DB $user_ids = array_column($pending_urls, 'sent_to_user_id'); $user_ids = array_filter($user_ids); // remove nulls if (empty($user_ids)) { throw new Exception('No valid user IDs found for resend.'); } $placeholders = implode(',', array_fill(0, count($user_ids), '?')); $stmt = $panelPdo->prepare("SELECT id, email FROM users WHERE id IN ($placeholders) AND status = 'active'"); $stmt->execute(array_values($user_ids)); $email_map = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); // Use incentive from original send $incentive = $selection['incentive_amount'] ?? $default_incentive; // Build REMINDER subject $subject = 'REMINDER: Invitation: Your Online Survey Opportunity from Relevant Reflex India'; $sent_count = 0; $failed_count = 0; $skipped_count = 0; $errors = []; $totalQueued = count($pending_urls); $ordinal = $new_dispatch_number == 2 ? '2nd' : '3rd'; // ─── STEP 1: Tiny header transaction — bump dispatch count + activity logs ─── // Commits in <100ms so it always succeeds before any timeout. $pdo->beginTransaction(); if ($new_dispatch_number == 2) { $stmt = $pdo->prepare(" UPDATE project_selections SET mail_dispatch_count = 2, second_dispatch_at = NOW() WHERE id = ? "); $stmt->execute([$selection_id]); } elseif ($new_dispatch_number == 3) { $stmt = $pdo->prepare(" UPDATE project_selections SET mail_dispatch_count = 3, third_dispatch_at = NOW() WHERE id = ? "); $stmt->execute([$selection_id]); } $detailsQueued = json_encode([ 'dispatch_number' => $new_dispatch_number, 'queued' => $totalQueued, 'type' => 'reminder' ]); $stmt = $pdo->prepare(" INSERT INTO selection_activity_log (selection_id, action, description, performed_by, created_at) VALUES (?, ?, ?, ?, NOW()) "); $stmt->execute([$selection_id, 'reminder_dispatch_' . $new_dispatch_number, $detailsQueued, $_SESSION['client_id']]); $stmt = $pdo->prepare(" INSERT INTO project_activity_log (project_id, client_id, action, details, ip_address, created_at) VALUES (?, ?, ?, ?, ?, NOW()) "); $stmt->execute([$selection['proj_id'], $_SESSION['client_id'], 'reminder_dispatch_' . $new_dispatch_number, $detailsQueued, $_SERVER['REMOTE_ADDR'] ?? '']); $pdo->commit(); // ─── STEP 2: Send JSON response IMMEDIATELY (before the long send) ─── $response = json_encode([ 'success' => true, 'sent_count' => $totalQueued, 'failed_count' => 0, 'skipped_count' => 0, 'dispatch_number' => $new_dispatch_number, 'errors' => [], 'message' => "Sending {$ordinal} dispatch reminder to {$totalQueued} recipient(s) in background. Page will refresh shortly to show delivery progress." ]); header('Content-Type: application/json'); header('Connection: close'); header('Content-Length: ' . strlen($response)); echo $response; // ─── STEP 3: Detach from HTTP request — script keeps running ─── if (function_exists('session_write_close')) { @session_write_close(); } if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } else { while (ob_get_level() > 0) { @ob_end_flush(); } @flush(); } // ─── STEP 4: Background send loop — autocommit per email ─── // No outer transaction: each successful email's UPDATE survives // even if the worker is later killed by LiteSpeed/PHP-FPM timeout. foreach ($pending_urls as $item) { $user_id = $item['sent_to_user_id']; $url_id = $item['id']; if (!$user_id || !isset($email_map[$user_id])) { $skipped_count++; continue; } $email = $email_map[$user_id]; $html = buildReminderEmail($item['rr_proxy_url'], $eloi, $incentive); $result = sendEmailViaSendGrid($email, $subject, $html); if ($result['success']) { $stmt = $pdo->prepare(" UPDATE survey_urls SET mail_dispatch_wave = ?, sent_at = NOW() WHERE id = ? "); $stmt->execute([$new_dispatch_number, $url_id]); $sent_count++; } else { $failed_count++; error_log("SendGrid resend error for $email: " . $result['message']); } } // ─── STEP 5: Best-effort final log (may not run if killed; safe) ─── try { $finalDetails = json_encode([ 'dispatch_number' => $new_dispatch_number, 'sent' => $sent_count, 'failed' => $failed_count, 'skipped' => $skipped_count, 'type' => 'reminder_completed' ]); $stmt = $pdo->prepare(" INSERT INTO selection_activity_log (selection_id, action, description, performed_by, created_at) VALUES (?, ?, ?, ?, NOW()) "); $stmt->execute([$selection_id, 'reminder_dispatch_' . $new_dispatch_number . '_completed', $finalDetails, $_SESSION['client_id'] ?? null]); } catch (Exception $e) { /* swallow — best-effort only */ } exit; } // ====================================================== // FIRST SEND FLOW (existing logic, preserved exactly) // ====================================================== // 2. Validate selection status — must be 'targeted' (URLs fully assigned) if (!in_array($selection['status'], ['targeted', 'draft'])) { if ($selection['status'] === 'live') { throw new Exception('Invitations have already been sent for this selection'); } throw new Exception('Selection must be in "targeted" status to send invitations. Current: ' . $selection['status']); } // 3. Get member count $stmt = $pdo->prepare("SELECT COUNT(*) FROM selection_members WHERE selection_id = ?"); $stmt->execute([$selection_id]); $member_count = (int)$stmt->fetchColumn(); if ($member_count === 0) { throw new Exception('No members in this selection'); } // 4. Get assigned URL count for this selection $stmt = $pdo->prepare(" SELECT COUNT(*) FROM survey_urls WHERE assigned_to_selection_id = ? AND status = 'assigned' "); $stmt->execute([$selection_id]); $assigned_count = (int)$stmt->fetchColumn(); if ($assigned_count < $member_count) { throw new Exception("Not all members have URLs assigned. Members: $member_count, URLs assigned: $assigned_count"); } // 5. Calculate & validate incentive $incentive = $incentive_override > 0 ? $incentive_override : $default_incentive; if ($incentive < $default_incentive) { throw new Exception("Incentive cannot be less than ₹" . number_format($default_incentive, 0) . " (default for {$eloi} min LOI)"); } // 6. Get all assigned URLs for this selection $stmt = $pdo->prepare(" SELECT id, rr_proxy_url, unique_identifier FROM survey_urls WHERE assigned_to_selection_id = ? AND status = 'assigned' ORDER BY id ASC "); $stmt->execute([$selection_id]); $urls = $stmt->fetchAll(PDO::FETCH_ASSOC); // 7. Get all member user_ids for this selection (ordered to pair with URLs) $stmt = $pdo->prepare(" SELECT user_id FROM selection_members WHERE selection_id = ? ORDER BY id ASC "); $stmt->execute([$selection_id]); $members = $stmt->fetchAll(PDO::FETCH_COLUMN); // 8. Pair members with URLs $send_queue = []; for ($i = 0; $i < count($members); $i++) { if (!isset($urls[$i])) break; $send_queue[] = [ 'user_id' => $members[$i], 'url_id' => $urls[$i]['id'], 'proxy_url' => $urls[$i]['rr_proxy_url'], 'unique_id' => $urls[$i]['unique_identifier'] ]; } if (empty($send_queue)) { throw new Exception('No URL-member pairs available to send'); } // 9. Look up member emails from panel DB $user_ids = array_column($send_queue, 'user_id'); $placeholders = implode(',', array_fill(0, count($user_ids), '?')); $stmt = $panelPdo->prepare("SELECT id, email FROM users WHERE id IN ($placeholders) AND status = 'active'"); $stmt->execute($user_ids); $email_map = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); // id => email // 10. Send emails $subject = 'Invitation: Your Online Survey Opportunity from Relevant Reflex India'; $sent_count = 0; $failed_count = 0; $errors = []; $totalQueued = count($send_queue); // ─── STEP 1: Tiny header transaction — mark selection live + project live + log ─── // Commits in <100ms so it always succeeds before any timeout. $pdo->beginTransaction(); $stmt = $pdo->prepare(" UPDATE project_selections SET status = 'live', incentive_amount = ?, invitations_sent_at = NOW(), mail_dispatch_count = 1, launched_at = CASE WHEN launched_at IS NULL THEN NOW() ELSE launched_at END WHERE id = ? "); $stmt->execute([$incentive, $selection_id]); if ($selection['project_status'] !== 'Live') { $stmt = $pdo->prepare(" UPDATE projects SET status = 'Live', made_live_at = CASE WHEN made_live_at IS NULL THEN NOW() ELSE made_live_at END WHERE id = ? "); $stmt->execute([$selection['proj_id']]); } $detailsQueued = json_encode([ 'dispatch_number' => 1, 'queued' => $totalQueued, 'incentive' => $incentive, 'eloi' => $eloi ]); $stmt = $pdo->prepare(" INSERT INTO selection_activity_log (selection_id, action, description, performed_by, created_at) VALUES (?, 'invitations_sent', ?, ?, NOW()) "); $stmt->execute([$selection_id, $detailsQueued, $_SESSION['client_id']]); $stmt = $pdo->prepare(" INSERT INTO project_activity_log (project_id, client_id, action, details, ip_address, created_at) VALUES (?, ?, 'invitations_sent', ?, ?, NOW()) "); $stmt->execute([$selection['proj_id'], $_SESSION['client_id'], $detailsQueued, $_SERVER['REMOTE_ADDR'] ?? '']); $pdo->commit(); // ─── STEP 2: Send JSON response IMMEDIATELY (before the long send loop) ─── $response = json_encode([ 'success' => true, 'sent_count' => $totalQueued, 'failed_count' => 0, 'dispatch_number' => 1, 'errors' => [], 'incentive' => $incentive, 'message' => "Sending invitations to {$totalQueued} recipient(s) in background. The selection is now live; emails will continue to deliver over the next few minutes. Refresh the page to see updated delivery status." ]); header('Content-Type: application/json'); header('Connection: close'); header('Content-Length: ' . strlen($response)); echo $response; // ─── STEP 3: Detach from HTTP request — script keeps running ─── if (function_exists('session_write_close')) { @session_write_close(); } if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } else { while (ob_get_level() > 0) { @ob_end_flush(); } @flush(); } // ─── STEP 4: Background send loop — autocommit per email ─── // No outer transaction: each successful email's UPDATE survives // even if the worker is later killed by LiteSpeed/PHP-FPM timeout. foreach ($send_queue as $item) { $user_id = $item['user_id']; $url_id = $item['url_id']; if (!isset($email_map[$user_id])) { $failed_count++; continue; } $email = $email_map[$user_id]; $html = buildInvitationEmail($item['proxy_url'], $eloi, $incentive); $result = sendEmailViaSendGrid($email, $subject, $html); if ($result['success']) { $stmt = $pdo->prepare(" UPDATE survey_urls SET status = 'sent', is_sent = 1, sent_to_user_id = ?, sent_at = NOW(), mail_dispatch_wave = 1 WHERE id = ? "); $stmt->execute([$user_id, $url_id]); $sent_count++; } else { $failed_count++; error_log("SendGrid error for $email: " . $result['message']); } } // ─── STEP 5: Best-effort final log (may not run if killed; safe) ─── try { $finalDetails = json_encode([ 'dispatch_number' => 1, 'sent' => $sent_count, 'failed' => $failed_count, 'incentive' => $incentive, 'eloi' => $eloi, 'type' => 'invitations_completed' ]); $stmt = $pdo->prepare(" INSERT INTO selection_activity_log (selection_id, action, description, performed_by, created_at) VALUES (?, 'invitations_completed', ?, ?, NOW()) "); $stmt->execute([$selection_id, $finalDetails, $_SESSION['client_id'] ?? null]); } catch (Exception $e) { /* swallow — best-effort only */ } exit; } catch (Exception $e) { if (isset($pdo) && $pdo->inTransaction()) { $pdo->rollBack(); } error_log("Send invitations error: " . $e->getMessage()); echo json_encode([ 'success' => false, 'message' => $e->getMessage() ]); }