# fluent-support/2.4.5/app/Hooks/Handlers/CleanupHandler.php

Fluent Support – Helpdesk &amp; Customer Support Ticket System, version 2.4.5. 300 lines.

- Page: https://pluginprobe.com/plugins/fluent-support/2.4.5/code/app/Hooks/Handlers/CleanupHandler.php
- Raw: https://pluginprobe.com/plugins/fluent-support/2.4.5/raw/app/Hooks/Handlers/CleanupHandler.php
- Modified: 2026-10-09T15:31:16+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/fluent-support/2.4.5/code/app/Hooks/Handlers/CleanupHandler.php#L10-L20`.

```php
<?php

namespace FluentSupport\App\Hooks\Handlers;

use FluentSupport\App\Models\Activity;
use FluentSupport\App\Models\AIActivityLogs;
use FluentSupport\App\Models\Attachment;
use FluentSupport\App\Models\Meta;
use FluentSupport\App\Services\EmailNotification\Settings;
use FluentSupport\App\Services\Helper;
use FluentSupport\App\Services\Includes\FileSystem;
use FluentSupport\App\Services\Integrations\Maintenance;
use FluentSupport\Database\Migrations\NotificationsMigrator;
use FluentSupport\Database\Migrations\NotificationUsersMigrator;

class CleanupHandler
{
    public function initHourlyTasks()
    {
        $this->cleanLiveActivities();
        $this->maybeDeleteOldTempFiles();
        $this->cleanAbandonedTempAttachments();
        $this->cleanExpiredAuthChallenges();
    }

    public function initDailyTasks()
    {
        $this->cleanActivityLogs();

        $this->cleanAIActivityLogs();

        $this->cleanInternalNotifications();
    }

    protected function cleanLiveActivities()
    {
        $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - 86400);

        Meta::where('key', '_live_activity')
            ->where('object_type', 'ticket_meta')
            ->where('updated_at', '<', $oldDateTime)
            ->delete();
    }

    protected function cleanExpiredAuthChallenges()
    {
        // Codes live for minutes (see the CODE_TTL_SECONDS constants), so an hour
        // never races a still-valid code.
        $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - HOUR_IN_SECONDS);

        // Older versions wrote these rows without created_at. They are long expired.
        Meta::whereIn('object_type', ['fs_login_hashes', 'fs_2fa'])
            ->where(function ($query) use ($oldDateTime) {
                $query->where('created_at', '<', $oldDateTime)
                    ->orWhereNull('created_at');
            })
            ->delete();
    }

    protected function cleanActivityLogs()
    {
        $settings = Helper::getOption('_activity_settings', []);

        if (empty($settings['delete_days'])) {
            $settings['delete_days'] = 14;
        }

        $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - ($settings['delete_days'] * 86400));

        $this->deleteInBatches(function () use ($oldDateTime) {
            return Activity::where('created_at', '<', $oldDateTime);
        });
    }

    /**
     * Delete the rows $query() matches a batch at a time, so a large backlog
     * (say, after shortening the retention) never locks the table in one
     * statement. Stops when the time budget is spent; the next run continues.
     *
     * @param callable $query Returns a fresh model query for the rows to delete.
     * @param int $batchSize
     * @param int $timeBudget Seconds.
     * @return int Rows deleted.
     */
    public function deleteInBatches(callable $query, $batchSize = 1000, $timeBudget = 20)
    {
        $started = time();
        $deleted = 0;

        do {
            $ids = $query()->orderBy('id', 'ASC')->limit($batchSize)->pluck('id')->all();
            if (!$ids) {
                break;
            }

            $deleted += $query()->whereIn('id', $ids)->delete();
        } while (count($ids) === $batchSize && (time() - $started) < $timeBudget);

        return $deleted;
    }

    protected function cleanAIActivityLogs()
    {
        if ((!defined('FLUENT_SUPPORT_PRO_DIR_FILE') || !Helper::openAIIntegrationStatus())
            && !Helper::fluentBotIntegrationStatus()) {
            return;
        }

        $settings = Helper::getOption('_ai_activity_settings', []);

        $defaultDays = 14;

        if (!empty($settings['delete_days'])) {
            $defaultDays = (int)$settings['delete_days'];
        }

        $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - ($defaultDays * 86400));

        $this->deleteInBatches(function () use ($oldDateTime) {
            return AIActivityLogs::where('created_at', '<', $oldDateTime);
        });
    }

    protected function cleanInternalNotifications()
    {
        global $wpdb;

        $notificationsTable = $wpdb->prefix . NotificationsMigrator::$tableName;
        $notificationUsersTable = $wpdb->prefix . NotificationUsersMigrator::$tableName;

        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $notificationsTable)) !== $notificationsTable
            || $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $notificationUsersTable)) !== $notificationUsersTable) {
            return;
        }

        $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - (15 * 86400));

        // A batch of old notifications at a time, recipients first.
        $started = time();
        do {
            $ids = array_map('intval', $wpdb->get_col($wpdb->prepare(
                "SELECT id FROM {$notificationsTable} WHERE created_at < %s ORDER BY id ASC LIMIT 1000",
                $oldDateTime
            )));
            if (!$ids) {
                break;
            }

            $idList = implode(',', $ids);
            $wpdb->query("DELETE FROM {$notificationUsersTable} WHERE notification_id IN ({$idList})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids
            $wpdb->query("DELETE FROM {$notificationsTable} WHERE id IN ({$idList})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids
        } while (count($ids) === 1000 && (time() - $started) < 20);
    }

    public function maybeDeleteAttachmentsOnClose($ticket)
    {
        $settings = (new Settings())->globalBusinessSettings();
        if ($settings['del_files_on_close'] == 'yes') {
            $this->deleteTicketAttachments($ticket);
        }
    }

    public function deleteTicketAttachments($ticket)
    {
        $uploadDir = wp_upload_dir();
        $dir = $uploadDir['basedir'] . '/' . FLUENT_SUPPORT_UPLOAD_DIR;

        $attachments = Attachment::where('ticket_id', $ticket->id)->get();

        if (!$attachments->isEmpty()) {
            $ticketDir = $dir . '/ticket_' . $ticket->id;
            if (is_dir($ticketDir)) {
                $this->deleteDir($ticketDir);
            }

            foreach ($attachments as $attachment) {
                if ($attachment->driver != 'local') {
                    do_action('fluent_support/delete_remote_attachment_' . $attachment->driver, $attachment, $ticket->id);
                } else if (file_exists($attachment->file_path)) {
                    wp_delete_file($attachment->file_path);
                }
            }

            Attachment::where('ticket_id', $ticket->id)->delete();
        }

    }

    private function deleteDir($dir)
    {
        if (!class_exists('\WP_Filesystem_Direct')) {
            require_once(ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php');
            require_once(ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php');
        }

        $fileSystemDirect = new \WP_Filesystem_Direct(false);
        $fileSystemDirect->rmdir($dir, true);
    }

    public function maybeMaintanceTask()
    {
        (new Maintenance())->maybeProcessData();

        $this->maybeRemoveOldScheuledActionLogs();
    }

    private function maybeDeleteOldTempFiles()
    {
        $dir = FileSystem::getDir();

        foreach (glob($dir . '/temp_files/*') as $filename) {
            if (time() - filectime($filename) >= 7200) { // 2 hours
                wp_delete_file($filename);
            }
        }
    }

    /**
     * Uploads stay 'in-active' until submitted with a ticket or reply. Abandoned ones
     * count against the upload quota, so drop them after the same 2 hours that
     * maybeDeleteOldTempFiles() uses for the files.
     *
     * NULL created_at rows are left alone: bulk reply writes them without a timestamp
     * and activates them within the same request.
     */
    protected function cleanAbandonedTempAttachments()
    {
        $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - 7200); // 2 hours

        // Each row hits the filesystem or remote storage, so drain a backlog over several runs.
        $attachments = Attachment::where('status', 'in-active')
            ->where('created_at', '<', $oldDateTime)
            ->limit(500)
            ->get();

        if ($attachments->isEmpty()) {
            return;
        }

        Attachment::purgeAttachments($attachments);

        Attachment::whereIn('id', $attachments->pluck('id')->toArray())->delete();
    }


    function maybeRemoveOldScheuledActionLogs($group_slug = 'fluent-support', $days_old = 7)
    {
        global $wpdb;

        $cutoff_date = gmdate('Y-m-d H:i:s', strtotime("-{$days_old} days"));

        $group_id = $wpdb->get_var($wpdb->prepare(
            "SELECT group_id FROM {$wpdb->prefix}actionscheduler_groups WHERE slug = %s",
            $group_slug
        ));

        if (!$group_id) {
            return false;
        }

        $actionsTable = $wpdb->prefix . 'actionscheduler_actions';
        $logsTable = $wpdb->prefix . 'actionscheduler_logs';
        $claimsTable = $wpdb->prefix . 'actionscheduler_claims';

        $deleted = 0;
        $started = time();

        // A batch at a time, and only our own actions, logs and claims.
        do {
            $rows = $wpdb->get_results($wpdb->prepare(
                "SELECT action_id, claim_id FROM {$actionsTable}
                WHERE group_id = %d AND status IN ('complete', 'failed') AND scheduled_date_gmt < %s
                ORDER BY action_id ASC LIMIT 1000",
                $group_id, $cutoff_date
            ));
            if (!$rows) {
                break;
            }

            $actionIds = implode(',', array_map('intval', wp_list_pluck($rows, 'action_id')));
            $claimIds = array_filter(array_map('intval', wp_list_pluck($rows, 'claim_id')));

            $wpdb->query("DELETE FROM {$logsTable} WHERE action_id IN ({$actionIds})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids
            $deleted += (int) $wpdb->query("DELETE FROM {$actionsTable} WHERE action_id IN ({$actionIds})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids

            if ($claimIds) {
                // Claims those actions held, once no action uses them.
                $claimList = implode(',', array_unique($claimIds));
                $wpdb->query(
                    "DELETE c FROM {$claimsTable} c
                    LEFT JOIN {$actionsTable} a ON a.claim_id = c.claim_id
                    WHERE c.claim_id IN ({$claimList}) AND a.action_id IS NULL"
                ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids
            }
        } while (count($rows) === 1000 && (time() - $started) < 20);

        return $deleted;
    }
}

```
