# fluent-support/2.4.5/app/Services/Tickets/TicketService.php

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

- Page: https://pluginprobe.com/plugins/fluent-support/2.4.5/code/app/Services/Tickets/TicketService.php
- Raw: https://pluginprobe.com/plugins/fluent-support/2.4.5/raw/app/Services/Tickets/TicketService.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/Services/Tickets/TicketService.php#L10-L20`.

```php
<?php

namespace FluentSupport\App\Services\Tickets;

use FluentSupport\App\Models\Attachment;
use FluentSupport\App\Models\Conversation;
use FluentSupport\App\Models\MailBox;
use FluentSupport\App\Models\Ticket;
use FluentSupport\App\Services\Helper;
use FluentSupport\App\Services\Includes\UploadService;
use FluentSupport\Framework\Support\Arr;

class TicketService
{
    /**
     * @param \FluentSupport\App\Models\Ticket $ticket
     * @param \FluentSupport\App\Models\Person $person
     * @param string $internalNote
     * @param string $silently 'yes' skips the close hooks.
     * @return \FluentSupport\App\Models\Ticket
     */

    public function close($ticket, $person, $internalNote = '', $silently = 'no')
    {
        if ($ticket->status != 'closed') {
            $ticket->status = 'closed';
            $ticket->resolved_at = current_time('mysql');
            $ticket->closed_by = $person->id;
            $ticket->total_close_time = current_time('timestamp') - strtotime($ticket->created_at);
            $ticket->save();

            if ('no' == $silently) {
                do_action('fluent_support/ticket_closed', $ticket, $person);
                do_action('fluent_support/ticket_closed_by_' . $person->person_type, $ticket, $person);
            }

            if (!$internalNote) {
                $internalNote = __('Ticket has been closed', 'fluent-support');
            }

            Conversation::create([
                'ticket_id'         => $ticket->id,
                'person_id'         => $person->id,
                'conversation_type' => 'internal_info',
                'content'           => $internalNote
            ]);
        }

        return $ticket;
    }

    /**
     * @param bool $deferHooks Skip the reopen hooks so a caller running inside a
     *                         transaction can fire them after commit (fireReopenHooks()).
     */
    public function reopen($ticket, $person, $deferHooks = false)
    {
        if ($ticket->status == 'closed') {
            $ticket->status = 'active';
            $ticket->waiting_since = current_time('mysql');
            $ticket->resolved_at = null;
            $ticket->save();

            if (!$deferHooks) {
                $this->fireReopenHooks($ticket, $person);
            }

            Conversation::create([
                'ticket_id'         => $ticket->id,
                'person_id'         => $person->id,
                'conversation_type' => 'internal_info',
                'content'           => __('Ticket has been reopened', 'fluent-support')
            ]);
        }

        return $ticket;
    }

    public function fireReopenHooks($ticket, $person)
    {
        /*
         * Fires when a closed ticket is reopened.
         *
         * @since v1.0.0
         * @param object $ticket
         * @param object $person
         */
        do_action('fluent_support/ticket_reopen', $ticket, $person);
        do_action('fluent_support/ticket_reopen_by_' . $person->person_type, $ticket, $person);
    }

    public function onAgentChange($ticket, $person)
    {
        do_action('fluent_support/ticket_agent_change', $ticket, $person);
        Conversation::create([
            'ticket_id'         => $ticket->id,
            'person_id'         => $person->id,
            'conversation_type' => 'internal_info',
            'content'           => $ticket->agent->user_id !== $person->user_id
                /* translators: %1$s is the name of the person who assigned the ticket, %2$s is the assigned agent's name */
                ? sprintf(__('%1$s assigned %2$s to this ticket', 'fluent-support'), $person->full_name, $ticket->agent->full_name)
                /* translators: %s is the agent's name */
                : sprintf(__('%s assigned this ticket to themselves', 'fluent-support'), $person->full_name)
        ]);

        return $person;
    }

    /**
     * Move the ticket's inline images and pending uploads into the ticket folder.
     *
     * @param array $data
     * @param array $disabledFields
     * @param \FluentSupport\App\Models\Ticket $ticket
     * @param object $customer
     * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's.
     * @return \FluentSupport\App\Models\Ticket $ticket
     * @since 1.5.7
     */
    public static function addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf = false)
    {
        Helper::tempImageMoveUploadDir($ticket->id, 'ticket-create', null, $customer->id);

        if (($attachmentsHashes = Arr::get($data, 'attachments')) && !in_array('file_upload', $disabledFields)) {
            $uploader = Helper::getCurrentAgent() ?: $customer;
            $uploaderIds = [$uploader->id];

            // A remote portal acting on_behalf uploads as the customer it files for.
            if ($onBehalf) {
                $uploaderIds[] = $customer->id;
            }

            $attachments = Attachment::whereIn('file_hash', $attachmentsHashes)
                ->where('status', 'in-active')
                ->whereNull('ticket_id')
                ->whereIn('person_id', $uploaderIds)
                ->get();

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

            $storageDriver = Helper::getUploadDriverKey();

            if ($storageDriver != 'local') {
                foreach ($attachments as $attachment) {
                    do_action_ref_array('fluent_support/finalize_file_upload_' . $storageDriver, [&$attachment, $ticket->id]);
                }
            }

            foreach ($attachments as $attachment) {
                if ($attachment->driver == 'local') {
                    $newFileInfo = UploadService::copyFileTicketFolder($attachment->file_path, $ticket->id);
                    if ($newFileInfo) {
                        $attachment->file_path = $newFileInfo['file_path'];
                        $attachment->full_url = $newFileInfo['url'];
                    }
                }

                $attachment->ticket_id = $ticket->id;
                $attachment->person_id = $customer->id;
                $attachment->status = 'active';
                $attachment->save();
            }

            $ticket->load('attachments');
        }

        return $ticket;
    }

    /**
     * Sanitize and create the ticket, then handle attachments, custom fields and hooks.
     * Used by TicketController::createTicket and ProTicketService::handleSplitTicket.
     *
     * @param array $ticketData
     * @param \FluentSupport\App\Models\Customer $customer
     * @return \FluentSupport\App\Models\Ticket
     */
    public function storeTicket($ticketData, $customer)
    {
        if (empty($ticketData['mailbox_id'])) {
            $mailbox = Helper::getDefaultMailBox();
            if ($mailbox) {
                $ticketData['mailbox_id'] = $mailbox->id;
            }
        } else {
            $mailbox = MailBox::findOrFail($ticketData['mailbox_id']);
        }

        // Empty when no agent is logged in, so cron, email piping and API callers pass.
        $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds();

        $this->assertMailboxAllowed($ticketData, $restrictedBoxes);

        if (!empty($ticketData['product_id'])) {
            $ticketData['product_source'] = 'local';
        }

        // Already unslashed at the request boundary. Don't unslash again or literal backslashes are lost.
        $ticketData['title'] = sanitize_text_field($ticketData['title']);
        $ticketData['content'] = wp_kses_post($ticketData['content']);

        if (!empty($ticketData['priority'])) {
            $ticketData['priority'] = sanitize_text_field($ticketData['priority']);
        }

        if (!empty($ticketData['client_priority'])) {
            $ticketData['client_priority'] = sanitize_text_field($ticketData['client_priority']);
        }

        $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer);

        // Check again: a filter may have routed the ticket into a restricted box.
        $this->assertMailboxAllowed($ticketData, $restrictedBoxes);

        do_action('fluent_support/before_ticket_create', $ticketData, $customer);

        $createdTicket = Ticket::create($ticketData);

        $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
        $disabledFields = array_diff($disabledFields, ['file_upload']);
        self::addTicketAttachments($ticketData, $disabledFields, $createdTicket, $customer);

        if (defined('FLUENTSUPPORTPRO') && !empty($ticketData['custom_fields'])) {
            $createdTicket->syncCustomFields($ticketData['custom_fields']);
        }

        $agent = Helper::getAgentByUserId();
        if ($agent) {
            $isAgentInitiated = Arr::get($ticketData, 'agent_initiated') === 'yes';
            $createdTicket->created_by = $agent->id;

            if ($isAgentInitiated) {
                // The agent's first response below sends its own email. Scoped to this
                // ticket and removed after its creation hooks, so later tickets in the
                // same request (MCP, imports, workflows) still get their emails.
                $suppressCreatedEmails = function ($shouldSend, $channel, $type, $ticket = null) use ($createdTicket) {
                    if ($channel === 'email'
                        && $ticket && (int) $ticket->id === (int) $createdTicket->id
                        && in_array($type, ['ticket_created_email_to_customer', 'ticket_created_email_to_admin', 'ticket_created_by_agent_email_to_customer'], true)) {
                        return false;
                    }
                    return $shouldSend;
                };
                add_filter('fluent_support/should_send_notification', $suppressCreatedEmails, 10, 4);

                // The channel it was sent from moves to the response; the ticket's source marks it as outreach.
                $channel = $createdTicket->source ?: 'web';
                $createdTicket->source = Ticket::SOURCE_AGENT_INITIATED;

                // A placeholder only: both apps show the agent's response as the opening message.
                /* translators: %s is the agent's name */
                $initializedMessage = sprintf(__('%s started this conversation', 'fluent-support'), $agent->full_name);

                $responseContent = apply_filters('fluent_support/parse_smartcode_data', $createdTicket->content, [
                    'customer' => $customer,
                    'agent'    => $agent,
                    'ticket'   => $createdTicket,
                ]);

                $responseContent = wp_kses_post($responseContent);

                $agentResponse = Conversation::create([
                    'ticket_id'         => $createdTicket->id,
                    'person_id'         => $agent->id,
                    'conversation_type' => 'response',
                    'content'           => $responseContent,
                    'source'            => $channel,
                ]);

                if ($attachmentHashes = Arr::get($ticketData, 'attachments', [])) {
                    Attachment::where('ticket_id', $createdTicket->id)
                        ->whereIn('file_hash', $attachmentHashes)
                        ->where('status', 'active')
                        ->update([
                            'person_id'       => $agentResponse->person_id,
                            'conversation_id' => $agentResponse->id
                        ]);

                    $agentResponse->load('attachments');
                }

                // The agent's message is the first response, so the ticket must say so:
                // the same counters ResponseService::createResponse() sets for an agent
                // reply, otherwise the ticket lists as new/unanswered and the agent's
                // next reply is measured as the first response.
                $now = current_time('mysql');
                // Unset in memory when the column default supplied it on insert.
                if (!$createdTicket->status || $createdTicket->status === 'new') {
                    $createdTicket->status = 'active';
                }
                if (!$createdTicket->agent_id) {
                    $createdTicket->agent_id = $agent->id;
                }
                $createdTicket->response_count      = (int) $createdTicket->response_count + 1;
                $createdTicket->first_response_time = 0;
                $createdTicket->last_agent_response = $now;
                $createdTicket->waiting_since       = $now;

                // The outreach email may render {{ticket.content}}, so expose the parsed body
                // during the hook. The ticket itself stores the "initialized" message.
                $createdTicket->content = $responseContent;

                do_action('fluent_support/agent_initiated_ticket_response', $agentResponse, $createdTicket, $agent);

                $createdTicket->content = $initializedMessage;

            }

            $createdTicket->save();
        }

        do_action('fluent_support/ticket_created', $createdTicket, $customer);
        do_action('fluent_support/ticket_created_behalf_of_customer', $createdTicket, $customer, $agent);

        if (!empty($suppressCreatedEmails)) {
            remove_filter('fluent_support/should_send_notification', $suppressCreatedEmails, 10);
        }

        return $createdTicket;
    }

    /**
     * Block ticket creation in a business box the acting agent is restricted from.
     *
     * @param array $ticketData
     * @param array $restrictedBoxes Already int-normalized by AgentTicketAccess
     * @return void
     * @throws \Exception
     */
    protected function assertMailboxAllowed($ticketData, $restrictedBoxes)
    {
        if (empty($restrictedBoxes) || empty($ticketData['mailbox_id'])) {
            return;
        }

        if (in_array((int) $ticketData['mailbox_id'], $restrictedBoxes, true)) {
            throw new \Exception(
                esc_html__('Sorry, you do not have permission to create a ticket in this business box', 'fluent-support')
            );
        }
    }

    // No auth check here: callers handle it, and it also runs with no current user (API, GDPR erasure, cron).
    public function deleteTicket($ticket, $agent = null)
    {
        if (!$agent) {
            $agent = Helper::getAgentByUserId();
        }

        $ticketData = [
            'id'    => $ticket->id,
            'title' => $ticket->title
        ];

        do_action('fluent_support/deleting_ticket', $ticket);
        $ticket->delete();
        do_action('fluent_support/ticket_deleted', $agent, $ticketData);
    }

}

```
