false, 'error' => 'Authentication required']); exit; } // Check admin privileges if (!$_SESSION['user']['is_admin']) { http_response_code(403); echo json_encode(['success' => false, 'error' => 'Admin privileges required']); exit; } $currentUserId = $_SESSION['user']['user_id']; // CSRF Protection for write operations if ($_SERVER['REQUEST_METHOD'] !== 'GET') { require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); exit; } } // Use centralized database connection $conn = Database::getConnection(); header('Content-Type: application/json'); $model = new RecurringTicketModel($conn); $auditLog = new AuditLogModel($conn); $method = $_SERVER['REQUEST_METHOD']; $id = isset($_GET['id']) ? (int)$_GET['id'] : null; $action = isset($_GET['action']) ? $_GET['action'] : null; switch ($method) { case 'GET': if ($id) { $recurring = $model->getById($id); echo json_encode(['success' => (bool)$recurring, 'recurring' => $recurring]); } else { $all = $model->getAll(true); echo json_encode(['success' => true, 'recurring_tickets' => $all]); } break; case 'POST': if ($action === 'toggle' && $id) { $result = $model->toggleActive($id); if (!empty($result['success'])) { $auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [ 'entity' => 'recurring_ticket', 'action' => 'toggle_active' ]); } echo json_encode($result); } else { $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data) || empty($data['schedule_type']) || empty($data['title_template'])) { echo json_encode(['success' => false, 'error' => 'schedule_type and title_template are required']); exit; } // Calculate next run time $nextRun = calculateNextRun( $data['schedule_type'], $data['schedule_day'] ?? null, $data['schedule_time'] ?? '09:00' ); $data['next_run_at'] = $nextRun; $data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1; $data['created_by'] = $currentUserId; $result = $model->create($data); if (!empty($result['success'])) { $auditLog->log($currentUserId, 'create', 'recurring_ticket', (string)($result['recurring_id'] ?? ''), [ 'title_template' => $data['title_template'], 'schedule_type' => $data['schedule_type'], 'schedule_day' => $data['schedule_day'] ?? null, 'schedule_time' => $data['schedule_time'] ?? '09:00' ]); } echo json_encode($result); } break; case 'PUT': if (!$id) { echo json_encode(['success' => false, 'error' => 'ID required']); exit; } $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data) || empty($data['schedule_type'])) { echo json_encode(['success' => false, 'error' => 'Invalid request data']); exit; } $existing = $model->getById($id); if (!$existing) { echo json_encode(['success' => false, 'error' => 'Recurring ticket not found']); exit; } $newDay = $data['schedule_day'] ?? null; $newTime = $data['schedule_time'] ?? '09:00'; // Only the schedule fields affect when the next occurrence fires. $scheduleChanged = (string)$existing['schedule_type'] !== (string)$data['schedule_type'] || (string)($existing['schedule_day'] ?? '') !== (string)($newDay ?? '') || substr((string)$existing['schedule_time'], 0, 5) !== substr((string)$newTime, 0, 5); $existingNextFuture = !empty($existing['next_run_at']) && strtotime($existing['next_run_at']) > time(); // Recompute only when the schedule actually changed (or the stored // next_run is already in the past). Editing an unrelated field (e.g. // title) must NOT move next_run_at backwards past an occurrence that // may already have fired, which would double-create a ticket. if ($scheduleChanged || !$existingNextFuture) { $data['next_run_at'] = calculateNextRun( $data['schedule_type'], $newDay, $newTime ); } else { $data['next_run_at'] = $existing['next_run_at']; } $data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1; $result = $model->update($id, $data); if (!empty($result['success'])) { $auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [ 'entity' => 'recurring_ticket', 'title_template' => $data['title_template'] ?? null, 'schedule_type' => $data['schedule_type'], 'schedule_day' => $newDay, 'schedule_time' => $newTime ]); } echo json_encode($result); break; case 'DELETE': if (!$id) { echo json_encode(['success' => false, 'error' => 'ID required']); exit; } $toDelete = $model->getById($id); $result = $model->delete($id); if (!empty($result['success'])) { $auditLog->log($currentUserId, 'delete', 'recurring_ticket', (string)$id, [ 'entity' => 'recurring_ticket', 'title_template' => $toDelete['title_template'] ?? 'unknown' ]); } echo json_encode($result); break; default: http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed']); } } catch (Exception $e) { error_log("Recurring tickets API error: " . $e->getMessage()); http_response_code(500); echo json_encode(['success' => false, 'error' => 'An internal error occurred']); } /** * Compute the SOONEST FUTURE occurrence matching the schedule. * * Returns 'Y-m-d H:i:s' in the app-configured timezone. The current period is * NOT skipped: a schedule whose time today/this-month is still in the future * fires then, not one period later. * * @param string $scheduleType daily|weekly|monthly * @param int|null $scheduleDay 1-7 (ISO, 1=Mon..7=Sun) weekly; 1-31 monthly * @param string $scheduleTime HH:MM or HH:MM:SS * @param DateTime|null $now Injected "now" for testing */ function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime, ?DateTime $now = null) { $tz = new DateTimeZone($GLOBALS['config']['TIMEZONE'] ?? date_default_timezone_get()); $now = $now ? $now : new DateTime('now', $tz); $parts = explode(':', $scheduleTime ?: '09:00'); $hour = (int)($parts[0] ?? 9); $minute = (int)($parts[1] ?? 0); $second = (int)($parts[2] ?? 0); $next = clone $now; switch ($scheduleType) { case 'weekly': $targetDow = (int)$scheduleDay; if ($targetDow < 1 || $targetDow > 7) { $targetDow = 1; } $next->setTime($hour, $minute, $second); $currentDow = (int)$next->format('N'); // 1=Mon .. 7=Sun $daysAhead = ($targetDow - $currentDow + 7) % 7; // Same weekday but the time already passed today -> next week. if ($daysAhead === 0 && $next <= $now) { $daysAhead = 7; } if ($daysAhead > 0) { $next->modify("+{$daysAhead} day"); $next->setTime($hour, $minute, $second); } break; case 'monthly': $day = max(1, min(31, (int)$scheduleDay)); // This month first, clamped to the month's length (e.g. day 31 -> Feb 28/29). $daysInMonth = (int)$now->format('t'); $next->setDate((int)$now->format('Y'), (int)$now->format('n'), min($day, $daysInMonth)); $next->setTime($hour, $minute, $second); if ($next <= $now) { // Already passed this month -> first day of next month, then clamp. $firstNext = clone $now; $firstNext->modify('first day of next month'); $daysInMonth = (int)$firstNext->format('t'); $next->setDate( (int)$firstNext->format('Y'), (int)$firstNext->format('n'), min($day, $daysInMonth) ); $next->setTime($hour, $minute, $second); } break; case 'daily': default: $next->setTime($hour, $minute, $second); if ($next <= $now) { $next->modify('+1 day'); $next->setTime($hour, $minute, $second); } break; } return $next->format('Y-m-d H:i:s'); }