makeHidden(self::PRIVATE_TICKET_FIELDS); } elseif (is_iterable($tickets)) { foreach ($tickets as $ticket) { if ($ticket instanceof Ticket) { $ticket->makeHidden(self::PRIVATE_TICKET_FIELDS); } } } return $tickets; } /** * @param object $customer * @param string $requestedStatus * @param array|null $options * @return object * @throws Exception * @since 1.8.1 */ public function getTickets($customer, $requestedStatus, $options = []) { $this->validateCustomer($customer); $statuses = $this->getTicketStatues($requestedStatus); return $this->ticketsAdditionalData($customer, $statuses, $options); } /** * @param array $customerAdditionalData * @param int $ticketId * @return array * @since 1.5.7 */ public function getTicket($customerAdditionalData, $ticketId) { $ticket = $this->getTicketByID($ticketId); // Same message as a denied ticket, so an unknown number reveals nothing. if (!$ticket) { throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support')); } // translators: %s is the time duration (e.g., "2 hours", "3 days") $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp'))); $customer = $this->getCustomer($customerAdditionalData, $ticket); $this->checkCustomerTicketAccess($customer, $ticket); return [ 'ticket' => $this->syncTicketAdditionData($ticket), 'responses' => $this->getResponses($ticket->id), 'sign_on_id' => $ticket->customer_id ]; } /** * @param object $customer * @param array $data * @param int $mailboxId * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's. * @return Ticket * @throws Exception */ public function createTicket($customer, $data, $mailboxId, $onBehalf = false) { $this->validateCustomer($customer); $data = $this->onlyAllowedPortalTicketFields($data); // Already unslashed at the request boundary. Don't unslash again or literal backslashes are lost. $data['title'] = sanitize_text_field($data['title']); $data['content'] = wp_kses_post($data['content']); $data['customer_id'] = $customer->id; $data['product_source'] = 'local'; $data['mailbox_id'] = $this->resolveMailboxId($mailboxId); $data['source'] = 'web'; $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []); $this->validateDisabledFields($data, $disabledFields); return $this->storeTicket($data, $customer, $disabledFields, $onBehalf); } /** * The validator returns the whole payload, and many fillable Ticket columns (agent_id, * status, privacy, ...) are agent-only, so keep only what a customer may set. * * @param array $data * @return array */ private function onlyAllowedPortalTicketFields($data) { $allowedKeys = [ 'title', 'content', 'product_id', 'client_priority', 'custom_data', 'attachments', 'message_id', ]; return array_intersect_key($data, array_flip($allowedKeys)); } /** * @param array $customerAdditionalData * @param int $ticketId * @param array $data * @return array * @throws Exception * @since 1.5.7 */ public function createResponse($customerAdditionalData, $ticketId, $data) { // Already unslashed at the request boundary; ResponseService sanitizes it. $data['conversation_type'] = 'response'; $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail(); $customer = $this->getCustomer($customerAdditionalData, $ticket); $this->checkCustomerTicketAccess($customer, $ticket, 'response'); // Checked before anything changes, so a blank reply neither reopens nor saves. if (!$this->hasReplyContent(Arr::get($data, 'content'))) { throw new \Exception(esc_html__('Please write a reply before sending.', 'fluent-support')); } // A reply reopens a closed ticket, as an emailed reply does, so it is never left // closed with an unanswered customer message in it. Reopened before the reply is // saved, as email piping does, so the reply hooks see an active ticket. Not when // the customer asked to close it with this reply. if ($ticket->status == 'closed' && Arr::get($data, 'close_ticket') != 'yes') { (new TicketService())->reopen($ticket, $customer); } $responseData = (new ResponseService())->createResponse($data, $customer, $ticket); if (!$responseData) { throw new \Exception(esc_html__('Please write a reply before sending.', 'fluent-support')); } $responseData['ticket'] = Ticket::find($ticket->id); $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id); $responseData['response']->load([ 'attachments' => function ($query) { $query->where('status', 'active'); } ]); return [ 'message' => __('Reply has been added', 'fluent-support'), 'response' => $responseData['response'], 'ticket' => self::hidePrivateTicketFields($responseData['ticket']) ]; } /** * @param array $customerAdditionalData * @param int $ticketId * @return array * @throws Exception */ public function closeTicket($customerAdditionalData, $ticketId) { $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail(); $customer = $this->getCustomer($customerAdditionalData, $ticket); $this->checkCustomerTicketAccess($customer, $ticket, 'close'); return [ 'message' => __('Ticket has been closed', 'fluent-support'), 'ticket' => self::hidePrivateTicketFields((new TicketService())->close($ticket, $customer)) ]; } /** * @param array $customerAdditionalData * @param int $ticketId * @return array * @throws Exception */ public function reOpenTicket($customerAdditionalData, $ticketId) { $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail(); $customer = $this->getCustomer($customerAdditionalData, $ticket); $this->checkCustomerTicketAccess($customer, $ticket, 'reopen'); return [ 'message' => __('Ticket has been opened again', 'fluent-support'), 'ticket' => self::hidePrivateTicketFields((new TicketService())->reopen($ticket, $customer)) ]; } /** * @param array $data * @param array $disabledFields * @return array $data * @since 1.5.7 */ private function validateDisabledFields($data, $disabledFields) { if (!in_array('priority', $disabledFields)) { $data['priority'] = sanitize_text_field($data['client_priority'] ?? ''); $data['client_priority'] = sanitize_text_field($data['client_priority'] ?? ''); } if (in_array('product_services', $disabledFields)) { unset($data['product_id']); } return $data; } /** * @param array $data * @param object $customer * @param array $disabledFields * @param bool $onBehalf * @return Ticket * @since 1.5.7 */ private function storeTicket($data, $customer, $disabledFields, $onBehalf = false) { /* * Filter ticket data before it's saved. * * @since v1.0.0 * @param array $data * @param object $customer */ $data = apply_filters('fluent_support/create_ticket_data', $data, $customer); /* * Fires before the ticket is created. * * @since v1.0.0 * @param array $data * @param object $customer */ do_action('fluent_support/before_ticket_create', $data, $customer); $ticket = Ticket::create($data); TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf); $this->addCustomData($data, $ticket); do_action('fluent_support/ticket_created', $ticket, $customer); return $ticket; } /** * @param array $data * @param object $ticket * @return void */ private function addCustomData($data, $ticket) { if (defined('FLUENTSUPPORTPRO')) { $customData = Arr::get($data, 'custom_data'); if ($customData) { $ticket->syncCustomFields($customData); } } } /** * @param object|null $customer * @since 1.5.7 * @throws Exception */ private function validateCustomer($customer) { if (!$customer) { throw new \Exception(esc_html__('Customer not found', 'fluent-support')); } if (!$customer->canAccessPortal()) { throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support')); } } /** * With a valid signed ticket hash the ticket's own customer is used, otherwise the current user. * * @param array $customerAdditionalData * @param object $ticket * @return object $customer * @throws Exception * * @since 1.5.7 */ public function getCustomer($customerAdditionalData, $ticket) { $intendedHash = Arr::get($customerAdditionalData, 'intended_ticket_hash'); if ($intendedHash && Helper::isPublicSignedTicketEnabled()) { if (!is_string($intendedHash) || !hash_equals((string) $ticket->hash, $intendedHash)) { throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support')); } $customer = $ticket->customer; } else { $onBehalf = Arr::get($customerAdditionalData, 'on_behalf'); // Signed links are off: see PortalPolicy::maybePublicSignedRequest(). if ($intendedHash && !$onBehalf && PortalPolicy::canActOnBehalf()) { throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support')); } // An agent acting for a customer keeps their own mailbox and visibility limits. // Site admins have full access and may not have an agent record at all. if ($onBehalf && !current_user_can('manage_options') && !(new AgentTicketAccess())->currentAgentCanAccess($ticket)) { throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support')); } $customer = $this->resolveCustomer($onBehalf, Arr::get($customerAdditionalData, 'user_ip')); } if (!$customer) { throw new \Exception(esc_html__('Sorry! No customer found', 'fluent-support')); } return $customer; } /** * @param string $requestedStatus * @return array * @since 1.8.1 */ private function getTicketStatues($requestedStatus) { $statuses = [ 'open' => ['new', 'active', 'on-hold'], 'all' => [], 'closed' => ['closed'] ]; return Arr::get($statuses, $requestedStatus, []); } /** * @param object $customer * @param array $statuses * @param array|null $options * @return object $tickets * @since 1.5.7 */ private function ticketsAdditionalData($customer, $statuses, $options = []) { $defaultOptions = [ 'search' => null, 'sorting' => null, 'filters' => null ]; $ticketOptions = wp_parse_args($options, $defaultOptions); $tickets = Ticket::with([ 'customer' => function ($query) { $query->select(['first_name', 'last_name', 'id']); }, 'agent' => function ($query) { $query->select(['first_name', 'last_name', 'id']); } ])->where('customer_id', $customer->id) ->when(!empty($ticketOptions['sorting'] && !empty($ticketOptions['sorting']['sort_by'])), function ($query) use ($ticketOptions) { return $query->orderBy(sanitize_sql_orderby($ticketOptions['sorting']['sort_by']), sanitize_sql_orderby($ticketOptions['sorting']['sort_type'])); }) ->when(!empty($options['filters']['product_id']), function ($query) use ($ticketOptions) { return $query->where('product_id', $ticketOptions['filters']['product_id']); }) ->when($statuses, function ($query) use ($statuses) { return $query->whereIn('status', $statuses); }) ->when($ticketOptions['search'], function ($query) use ($ticketOptions) { return $query->searchBy($ticketOptions['search']); }) ->when(empty($ticketOptions['sorting']), function ($query) { return $query->latest('updated_at'); }) ->paginate(); foreach ($tickets as $ticket) { // translators: %s is the time duration (e.g., "2 hours", "3 days") $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp'))); $ticket->preview_response = $ticket->getLastResponse(); } return self::hidePrivateTicketFields($tickets); } /** * Keeps only scalar values, so sanitize_text_field() never gets an array. * An empty result makes resolveCustomer() use the logged-in user. * * @param mixed $onBehalf * @return array */ public function sanitizeOnBehalf($onBehalf) { if (!is_array($onBehalf)) { return []; } $sanitized = []; foreach ($onBehalf as $key => $value) { if (is_scalar($value)) { $sanitized[sanitize_text_field($key)] = sanitize_text_field($value); } } return $sanitized; } /** * Find the customer for $onBehalf (or the current user), creating it when $forceCreate is set. * * @param array $onBehalf * @param string $userIp * @param bool $forceCreate * @return Customer|false */ public function resolveCustomer($onBehalf, $userIp, $forceCreate = false) { if (!$onBehalf) { $user = get_user_by('ID', get_current_user_id()); if (!$user) { return false; } $onBehalf = [ 'user_id' => $user->ID, 'email' => $user->user_email, 'last_ip_address' => $userIp ]; } if ($forceCreate) { return Customer::maybeCreateCustomer($onBehalf); } return Customer::getCustomerFromData($onBehalf); } /** * Whether a reply has something to read: text, or an image. Editors leave markup * such as
behind when the text is deleted. * * @param mixed $content * @return bool */ private function hasReplyContent($content) { // ResponseService refuses whatever empty() treats as empty, "0" included. if (!is_string($content) || empty($content)) { return false; } if (stripos($content, '