mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-10-23 03:37:01 +09:00
Migrate Email, Extension and Support modules to Doctrine (#3863)
* Migrate Email, Extension, Support modules to Doctrine ORM * Add 'initialises' to cspell.json custom words * Add patch76 and fix extension deactivation event * Bump database patch version to 76 * Refactor Support APIs to mapped Doctrine flows * Handle legacy email queue and ticket filters * Migrate Support module to Doctrine ORM * Refactor services to use typed repositories * Route order ticket count and ticket messages through repositories Drops the last two thin Support\Service wrappers, calling the SupportTicketRepository and SupportTicketMessageRepository directly from the cross-module callers. * Rename email queue table and add patch * Preserve unlimited email queue processing * Preserve unlimited email queue processing
This commit is contained in:
1 parent
e7ca860214
commit
5ee72a234a
58 files changed
+4156
-2131
No files matched your search
+2
-1
@@ -55,7 +55,8 @@
|
||||
"mysite",
|
||||
"myhost",
|
||||
"inversed",
|
||||
"Autoresponder"
|
||||
"Autoresponder",
|
||||
"initialises"
|
||||
],
|
||||
|
||||
"ignoreWords": [
|
||||
|
||||
@@ -303,7 +303,7 @@ LOCK TABLES `setting` WRITE;
|
||||
|
||||
INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1,'last_patch','75',0,NULL,NULL,'2024-12-30 12:00:00','2024-12-30 12:00:00'),
|
||||
(1,'last_patch','77',0,NULL,NULL,'2024-12-30 12:00:00','2024-12-30 12:00:00'),
|
||||
(2,'company_name','Company Name',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
|
||||
(3,'company_email','support@yourcompany.com',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
|
||||
(4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
|
||||
|
||||
@@ -46,6 +46,7 @@ CREATE TABLE `activity_client_email` (
|
||||
`content_html` text,
|
||||
`content_text` text,
|
||||
`created_at` datetime DEFAULT NULL,
|
||||
`updated_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `client_id_idx` (`client_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -635,12 +636,12 @@ CREATE TABLE `support_kb_article_category` (
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `mod_email_queue`
|
||||
-- Table structure for table `email_queue`
|
||||
--
|
||||
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `mod_email_queue` (
|
||||
CREATE TABLE `email_queue` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`recipient` varchar(255) NOT NULL,
|
||||
`sender` varchar(255) NOT NULL,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace FOSSBilling\Doctrine;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* Standard `createdAt` / `updatedAt` lifecycle and accessors for Doctrine entities.
|
||||
*
|
||||
* Classes that use this trait should:
|
||||
* - Add the class-level `#[ORM\HasLifecycleCallbacks]` attribute
|
||||
* - Implement {@see \FOSSBilling\Interfaces\TimestampInterface}
|
||||
*
|
||||
* The trait provides:
|
||||
* - `createdAt` and `updatedAt` properties mapped to the `created_at` / `updated_at`
|
||||
* `DATETIME_MUTABLE` columns
|
||||
* - `onPrePersist()` lifecycle callback that initialises both timestamps
|
||||
* - `onPreUpdate()` lifecycle callback that bumps `updatedAt`
|
||||
* - Getter and setter methods satisfying {@see \FOSSBilling\Interfaces\TimestampInterface}
|
||||
*
|
||||
* Special-cases:
|
||||
* - Entities that must not auto-bump `updatedAt` on every Doctrine update (e.g. when
|
||||
* a non-state-changing field such as a view counter is updated) can override
|
||||
* `onPreUpdate()` to be a no-op and manage `updatedAt` from explicit setters.
|
||||
* - Entities that need a different column name (extremely rare) can redeclare the
|
||||
* property with a new `#[ORM\Column(name: '...')]` attribute — the redeclaration
|
||||
* takes precedence over the trait property.
|
||||
*/
|
||||
trait TimestampTrait
|
||||
{
|
||||
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $createdAt = null;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $updatedAt = null;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
$now = new \DateTime();
|
||||
$this->createdAt ??= $now;
|
||||
$this->updatedAt = $now;
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function onPreUpdate(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(?\DateTime $createdAt): void
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(?\DateTime $updatedAt): void
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace FOSSBilling\Enums;
|
||||
|
||||
/**
|
||||
* Canonical client order lifecycle status.
|
||||
*
|
||||
* The underlying string values must match the existing RedBean `client_order.status`
|
||||
* column values and the `Model_ClientOrder::STATUS_*` constants so the enum is
|
||||
* interchangeable with the legacy code paths.
|
||||
*/
|
||||
enum ClientOrderStatusEnum: string
|
||||
{
|
||||
case PENDING_SETUP = 'pending_setup';
|
||||
case FAILED_SETUP = 'failed_setup';
|
||||
case FAILED_RENEW = 'failed_renew';
|
||||
case ACTIVE = 'active';
|
||||
case CANCELED = 'canceled';
|
||||
case SUSPENDED = 'suspended';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $c): string => $c->value, self::cases());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace FOSSBilling\Enums;
|
||||
|
||||
/**
|
||||
* Canonical client lifecycle status.
|
||||
*
|
||||
* The underlying string values must match the existing RedBean `client.status`
|
||||
* column values and the `Model_Client::ACTIVE/SUSPENDED/CANCELED` constants
|
||||
* so the enum is interchangeable with the legacy code paths.
|
||||
*/
|
||||
enum ClientStatusEnum: string
|
||||
{
|
||||
case ACTIVE = 'active';
|
||||
case SUSPENDED = 'suspended';
|
||||
case CANCELED = 'canceled';
|
||||
}
|
||||
@@ -121,4 +121,39 @@ class Pagination implements InjectionAwareInterface
|
||||
|
||||
return $this->buildPaginatedResponse($pagination->page, $pagination->perPage, $total, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate results from a Doctrine QueryBuilder and apply a custom mapper to each entity.
|
||||
*
|
||||
* Use this when the entity's `toApiArray()` requires context (identity, deep flag,
|
||||
* cross-module lookups) that the default `paginateDoctrineQuery()` cannot provide.
|
||||
* The caller supplies a closure that performs the mapping.
|
||||
*
|
||||
* @param QueryBuilder $qb the Doctrine QueryBuilder instance to paginate
|
||||
* @param PaginationOptions $pagination pagination options
|
||||
* @param callable $mapper fn(object $entity): array applied to each row
|
||||
*
|
||||
* @return array{
|
||||
* pages: int, // Total number of pages
|
||||
* page: int, // Current page number
|
||||
* per_page: int, // Items per page
|
||||
* total: int, // Total number of items
|
||||
* list: array // List of paginated items as arrays
|
||||
* }
|
||||
*/
|
||||
public function paginateMappedQuery(QueryBuilder $qb, PaginationOptions $pagination, callable $mapper): array
|
||||
{
|
||||
$offset = ($pagination->page - 1) * $pagination->perPage;
|
||||
$qb->setFirstResult($offset)
|
||||
->setMaxResults($pagination->perPage);
|
||||
$paginator = new DoctrinePaginator($qb, true);
|
||||
$total = count($paginator);
|
||||
|
||||
$list = [];
|
||||
foreach ($paginator as $entity) {
|
||||
$list[] = $mapper($entity);
|
||||
}
|
||||
|
||||
return $this->buildPaginatedResponse($pagination->page, $pagination->perPage, $total, $list);
|
||||
}
|
||||
}
|
||||
@@ -481,6 +481,8 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
73 => 'patch73',
|
||||
74 => 'patch74',
|
||||
75 => 'patch75',
|
||||
76 => 'patch76',
|
||||
77 => 'patch77',
|
||||
];
|
||||
ksort($patches, SORT_NATURAL);
|
||||
|
||||
@@ -641,8 +643,8 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
}
|
||||
|
||||
// If the Kb extension exists, uninstall it.
|
||||
$kb_ext = $ext_service->findExtension('mod', 'kb');
|
||||
if ($kb_ext instanceof \Model_Extension) {
|
||||
$kb_ext = $ext_service->getExtensionRepository()->findOneByTypeAndName('mod', 'kb');
|
||||
if ($kb_ext instanceof \Box\Mod\Extension\Entity\Extension) {
|
||||
$ext_service->deactivate($kb_ext);
|
||||
$ext_service->uninstall('mod', 'kb');
|
||||
}
|
||||
@@ -664,8 +666,8 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
try {
|
||||
$ext_service = $this->di['mod_service']('extension');
|
||||
// If the queue extension exists, uninstall it.
|
||||
$queue_ext = $ext_service->findExtension('mod', 'queue');
|
||||
if ($queue_ext instanceof \Model_Extension) {
|
||||
$queue_ext = $ext_service->getExtensionRepository()->findOneByTypeAndName('mod', 'queue');
|
||||
if ($queue_ext instanceof \Box\Mod\Extension\Entity\Extension) {
|
||||
$ext_service->deactivate($queue_ext);
|
||||
$ext_service->uninstall('mod', 'queue');
|
||||
}
|
||||
@@ -1097,8 +1099,8 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
|
||||
$this->executeSql("DELETE FROM extension_meta WHERE extension = 'mod_spamchecker' AND meta_key = 'config'");
|
||||
|
||||
$spamcheckerExt = $extService->findExtension('mod', 'spamchecker');
|
||||
if ($spamcheckerExt instanceof \Model_Extension) {
|
||||
$spamcheckerExt = $extService->getExtensionRepository()->findOneByTypeAndName('mod', 'spamchecker');
|
||||
if ($spamcheckerExt instanceof \Box\Mod\Extension\Entity\Extension) {
|
||||
$extService->deactivate($spamcheckerExt);
|
||||
$extService->uninstall('mod', 'spamchecker');
|
||||
}
|
||||
@@ -1934,6 +1936,26 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
$this->executeSql('ALTER TABLE `client` DROP COLUMN `document_type`, DROP COLUMN `document_nr`;');
|
||||
}
|
||||
|
||||
private function patch76(): void
|
||||
{
|
||||
// The `activity_client_email` table was missing an `updated_at` column,
|
||||
// but the Doctrine entity for it now expects one. Add the column for
|
||||
// installations that were created before the entity was migrated.
|
||||
if (!$this->tableHasColumn('activity_client_email', 'updated_at')) {
|
||||
$this->executeSql('ALTER TABLE `activity_client_email` ADD COLUMN `updated_at` datetime DEFAULT NULL AFTER `created_at`');
|
||||
}
|
||||
}
|
||||
|
||||
private function patch77(): void
|
||||
{
|
||||
// The email queue table was renamed from `mod_email_queue` to
|
||||
// `email_queue` when the `QueuedEmail` Doctrine entity was introduced.
|
||||
// Rename it for installations that still use the legacy table name.
|
||||
if ($this->tableExists('mod_email_queue') && !$this->tableExists('email_queue')) {
|
||||
$this->executeSql('RENAME TABLE `mod_email_queue` TO `email_queue`');
|
||||
}
|
||||
}
|
||||
|
||||
private function generateDownloadableStoredFilename(): string
|
||||
{
|
||||
do {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2025 FOSSBilling
|
||||
* Copyright 2011-2021 BoxBilling, Inc.
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
class Model_SupportTicket extends RedBeanPHP\SimpleModel
|
||||
{
|
||||
final public const string OPENED = 'open';
|
||||
final public const string ONHOLD = 'on_hold';
|
||||
final public const string CLOSED = 'closed';
|
||||
|
||||
final public const string REL_TYPE_ORDER = 'order';
|
||||
final public const array ALLOWED_REL_TYPES = [
|
||||
self::REL_TYPE_ORDER,
|
||||
];
|
||||
|
||||
final public const string REL_STATUS_PENDING = 'pending';
|
||||
final public const string REL_STATUS_COMPLETE = 'complete';
|
||||
final public const array ALLOWED_REL_STATUSES = [
|
||||
self::REL_STATUS_PENDING,
|
||||
self::REL_STATUS_COMPLETE,
|
||||
];
|
||||
|
||||
final public const string REL_TASK_CANCEL = 'cancel';
|
||||
final public const string REL_TASK_UPGRADE = 'upgrade';
|
||||
final public const array ALLOWED_REL_TASKS = [
|
||||
self::REL_TASK_CANCEL,
|
||||
self::REL_TASK_UPGRADE,
|
||||
];
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2025 FOSSBilling
|
||||
* Copyright 2011-2021 BoxBilling, Inc.
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
class Model_SupportTicketMessage extends RedBeanPHP\SimpleModel
|
||||
{
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2025 FOSSBilling
|
||||
* Copyright 2011-2021 BoxBilling, Inc.
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
class Model_SupportTicketNote extends RedBeanPHP\SimpleModel
|
||||
{
|
||||
}
|
||||
@@ -13,6 +13,7 @@ declare(strict_types=1);
|
||||
namespace Box\Mod\Currency\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
use Symfony\Component\Intl\Currencies;
|
||||
@@ -22,6 +23,8 @@ use Symfony\Component\Intl\Currencies;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class Currency implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
@@ -36,12 +39,6 @@ class Currency implements ApiArrayInterface, TimestampInterface
|
||||
|
||||
private ?float $conversionRateFloat = null;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $updatedAt = null;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 3, unique: true)]
|
||||
private string $code,
|
||||
@@ -59,20 +56,6 @@ class Currency implements ApiArrayInterface, TimestampInterface
|
||||
];
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
$now = new \DateTime();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -141,14 +124,4 @@ class Currency implements ApiArrayInterface, TimestampInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setCreatedAt(?\DateTime $createdAt): void
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(?\DateTime $updatedAt): void
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ declare(strict_types=1);
|
||||
namespace Box\Mod\Email\Api;
|
||||
|
||||
use FOSSBilling\PaginationOptions;
|
||||
use FOSSBilling\Tools;
|
||||
use FOSSBilling\Validation\Api\RequiredParams;
|
||||
|
||||
class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
@@ -31,27 +30,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('email', 'view_email_history');
|
||||
|
||||
[$sql, $params] = $this->getService()->getSearchQuery($data);
|
||||
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $params, PaginationOptions::fromArray($data));
|
||||
$repo = $this->getService()->getActivityClientEmailRepository();
|
||||
|
||||
foreach ($pager['list'] as $key => $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$pager['list'][$key] = [
|
||||
'id' => $item['id'],
|
||||
'client_id' => $item['client_id'],
|
||||
'sender' => $item['sender'] ?? '',
|
||||
'recipients' => $item['recipients'] ?? '',
|
||||
'subject' => $item['subject'] ?? '',
|
||||
'content_html' => Tools::sanitizeContent($item['content_html'] ?? ''),
|
||||
'content_text' => $item['content_text'] ?? '',
|
||||
'created_at' => $item['created_at'] ?? '',
|
||||
'updated_at' => $item['updated_at'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $pager;
|
||||
return $this->getDi()['pager']->paginateDoctrineQuery(
|
||||
$repo->getSearchQueryBuilder($data),
|
||||
PaginationOptions::fromArray($data),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,11 +97,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('email', 'send_emails');
|
||||
|
||||
$model = $this->getDi()['db']->findOne('ActivityClientEmail', 'id = ?', [$data['id']]);
|
||||
|
||||
if (!$model instanceof \Model_ActivityClientEmail) {
|
||||
throw new \FOSSBilling\Exception('Email not found');
|
||||
}
|
||||
$model = $this->getService()->getActivityClientEmailRepository()->findOneByIdOrFail((int) $data['id']);
|
||||
|
||||
return $this->getService()->resend($model);
|
||||
}
|
||||
@@ -132,14 +112,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('email', 'delete_email_history');
|
||||
|
||||
$model = $this->getDi()['db']->findOne('ActivityClientEmail', 'id = ?', [$data['id']]);
|
||||
$em = $this->getDi()['em'];
|
||||
$model = $this->getService()->getActivityClientEmailRepository()->findOneByIdOrFail((int) $data['id']);
|
||||
|
||||
if (!$model instanceof \Model_ActivityClientEmail) {
|
||||
throw new \FOSSBilling\Exception('Email not found');
|
||||
}
|
||||
|
||||
$id = $model->id;
|
||||
$this->getDi()['db']->trash($model);
|
||||
$id = $model->getId();
|
||||
$em->remove($model);
|
||||
$em->flush();
|
||||
|
||||
$this->getDi()['logger']->info('Deleted email #%s', $id);
|
||||
|
||||
@@ -447,23 +425,11 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('email', 'view_email_history');
|
||||
|
||||
[$sql, $params] = $this->getService()->queueGetSearchQuery($data);
|
||||
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $params, PaginationOptions::fromArray($data));
|
||||
$repo = $this->getService()->getQueuedEmailRepository();
|
||||
|
||||
foreach ($pager['list'] as $key => $item) {
|
||||
$pager['list'][$key] = [
|
||||
'id' => $item['id'] ?? '',
|
||||
'recipient' => $item['recipient'] ?? '',
|
||||
'subject' => $item['subject'] ?? '',
|
||||
'content' => $item['content'] ?? '',
|
||||
'to_name' => $item['to_name'] ?? '',
|
||||
'status' => $item['status'] ?? '',
|
||||
'tries' => $item['tries'] ?? '',
|
||||
'created_at' => $item['created_at'] ?? '',
|
||||
'updated_at' => $item['updated_at'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $pager;
|
||||
return $this->getDi()['pager']->paginateDoctrineQuery(
|
||||
$repo->getSearchQueryBuilder($data),
|
||||
PaginationOptions::fromArray($data),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ declare(strict_types=1);
|
||||
namespace Box\Mod\Email\Api;
|
||||
|
||||
use FOSSBilling\PaginationOptions;
|
||||
use FOSSBilling\Tools;
|
||||
use FOSSBilling\Validation\Api\RequiredParams;
|
||||
|
||||
class Client extends \FOSSBilling\Api\AbstractApi
|
||||
@@ -31,24 +30,13 @@ class Client extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$client = $this->getIdentity();
|
||||
$data['client_id'] = $client->id;
|
||||
[$sql, $params] = $this->getService()->getSearchQuery($data);
|
||||
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $params, PaginationOptions::fromArray($data));
|
||||
|
||||
foreach ($pager['list'] as $key => $item) {
|
||||
$pager['list'][$key] = [
|
||||
'id' => $item['id'] ?? '',
|
||||
'client_id' => $item['client_id'] ?? '',
|
||||
'sender' => $item['sender'] ?? '',
|
||||
'recipients' => $item['recipients'] ?? '',
|
||||
'subject' => $item['subject'] ?? '',
|
||||
'content_html' => Tools::sanitizeContent($item['content_html'] ?? ''),
|
||||
'content_text' => $item['content_text'] ?? '',
|
||||
'created_at' => $item['created_at'] ?? '',
|
||||
'updated_at' => $item['updated_at'] ?? '',
|
||||
];
|
||||
}
|
||||
$repo = $this->getService()->getActivityClientEmailRepository();
|
||||
|
||||
return $pager;
|
||||
return $this->getDi()['pager']->paginateDoctrineQuery(
|
||||
$repo->getSearchQueryBuilder($data),
|
||||
PaginationOptions::fromArray($data),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,11 +49,10 @@ class Client extends \FOSSBilling\Api\AbstractApi
|
||||
#[RequiredParams(['id' => 'Email ID was not passed'])]
|
||||
public function get($data)
|
||||
{
|
||||
$model = $this->getService()->findOneForClientById($this->getIdentity(), $data['id']);
|
||||
|
||||
if (!$model instanceof \Model_ActivityClientEmail) {
|
||||
throw new \FOSSBilling\Exception('Email not found');
|
||||
}
|
||||
$model = $this->getService()->getActivityClientEmailRepository()->findOneForClientByIdOrFail(
|
||||
(int) $this->getIdentity()->id,
|
||||
(int) $data['id'],
|
||||
);
|
||||
|
||||
return $this->getService()->toApiArray($model);
|
||||
}
|
||||
@@ -85,10 +72,10 @@ class Client extends \FOSSBilling\Api\AbstractApi
|
||||
$this->getDi()['rate_limiter']->consumeOrThrow('client_email_resend_ip', (string) $this->getIp());
|
||||
$this->getDi()['rate_limiter']->consumeOrThrow('client_email_resend_account', 'client:' . $client->id);
|
||||
|
||||
$model = $this->getService()->findOneForClientById($client, $data['id']);
|
||||
if (!$model instanceof \Model_ActivityClientEmail) {
|
||||
throw new \FOSSBilling\Exception('Email not found');
|
||||
}
|
||||
$model = $this->getService()->getActivityClientEmailRepository()->findOneForClientByIdOrFail(
|
||||
(int) $client->id,
|
||||
(int) $data['id'],
|
||||
);
|
||||
|
||||
return $this->getService()->resend($model);
|
||||
}
|
||||
@@ -96,18 +83,19 @@ class Client extends \FOSSBilling\Api\AbstractApi
|
||||
/**
|
||||
* Remove email from system.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @throws \FOSSBilling\Exception
|
||||
*/
|
||||
#[RequiredParams(['id' => 'Email ID was not passed'])]
|
||||
public function delete($data)
|
||||
public function delete($data): bool
|
||||
{
|
||||
$model = $this->getService()->findOneForClientById($this->getIdentity(), $data['id']);
|
||||
if (!$model instanceof \Model_ActivityClientEmail) {
|
||||
throw new \FOSSBilling\Exception('Email not found');
|
||||
}
|
||||
$em = $this->getDi()['em'];
|
||||
$model = $this->getService()->getActivityClientEmailRepository()->findOneForClientByIdOrFail(
|
||||
(int) $this->getIdentity()->id,
|
||||
(int) $data['id'],
|
||||
);
|
||||
$em->remove($model);
|
||||
$em->flush();
|
||||
|
||||
return $this->getService()->rm($model);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Email\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
/**
|
||||
* One row per email that FOSSBilling sent to a client.
|
||||
*
|
||||
* Used as a delivery log and to power the "resend" action on the
|
||||
* client-facing email history view.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: \Box\Mod\Email\Repository\ActivityClientEmailRepository::class)]
|
||||
#[ORM\Table(name: 'activity_client_email')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ActivityClientEmail implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'client_id', type: Types::INTEGER, nullable: true)]
|
||||
private ?int $clientId = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $sender = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $recipients = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $subject = null;
|
||||
|
||||
#[ORM\Column(name: 'content_html', type: Types::TEXT, nullable: true)]
|
||||
private ?string $contentHtml = null;
|
||||
|
||||
#[ORM\Column(name: 'content_text', type: Types::TEXT, nullable: true)]
|
||||
private ?string $contentText = null;
|
||||
|
||||
public function toApiArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'client_id' => $this->clientId,
|
||||
'sender' => $this->sender,
|
||||
'recipients' => $this->recipients,
|
||||
'subject' => $this->subject,
|
||||
'content_html' => $this->contentHtml,
|
||||
'content_text' => $this->contentText,
|
||||
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getClientId(): ?int
|
||||
{
|
||||
return $this->clientId;
|
||||
}
|
||||
|
||||
public function setClientId(?int $clientId): self
|
||||
{
|
||||
$this->clientId = $clientId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSender(): ?string
|
||||
{
|
||||
return $this->sender;
|
||||
}
|
||||
|
||||
public function setSender(?string $sender): self
|
||||
{
|
||||
$this->sender = $sender;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRecipients(): ?string
|
||||
{
|
||||
return $this->recipients;
|
||||
}
|
||||
|
||||
public function setRecipients(?string $recipients): self
|
||||
{
|
||||
$this->recipients = $recipients;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubject(): ?string
|
||||
{
|
||||
return $this->subject;
|
||||
}
|
||||
|
||||
public function setSubject(?string $subject): self
|
||||
{
|
||||
$this->subject = $subject;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContentHtml(): ?string
|
||||
{
|
||||
return $this->contentHtml;
|
||||
}
|
||||
|
||||
public function setContentHtml(?string $contentHtml): self
|
||||
{
|
||||
$this->contentHtml = $contentHtml;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContentText(): ?string
|
||||
{
|
||||
return $this->contentText;
|
||||
}
|
||||
|
||||
public function setContentText(?string $contentText): self
|
||||
{
|
||||
$this->contentText = $contentText;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Email\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
/**
|
||||
* Pending email in the queue processed by the email-batch cron task.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: \Box\Mod\Email\Repository\QueuedEmailRepository::class)]
|
||||
#[ORM\Table(name: 'email_queue')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class QueuedEmail implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_SENT = 'sent';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_UNSENT = 'unsent';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
private string $recipient;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
private string $sender;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
private string $subject;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private string $content;
|
||||
|
||||
#[ORM\Column(name: 'to_name', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $toName = null;
|
||||
|
||||
#[ORM\Column(name: 'from_name', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $fromName = null;
|
||||
|
||||
#[ORM\Column(name: 'client_id', type: Types::INTEGER, nullable: true)]
|
||||
private ?int $clientId = null;
|
||||
|
||||
#[ORM\Column(name: 'admin_id', type: Types::INTEGER, nullable: true)]
|
||||
private ?int $adminId = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, nullable: true)]
|
||||
private ?int $priority = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $tries = 0;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20, options: ['default' => self::STATUS_PENDING])]
|
||||
private string $status = self::STATUS_PENDING;
|
||||
|
||||
public function toApiArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'recipient' => $this->recipient,
|
||||
'subject' => $this->subject,
|
||||
'content' => $this->content,
|
||||
'to_name' => $this->toName,
|
||||
'status' => $this->status,
|
||||
'tries' => $this->tries,
|
||||
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getRecipient(): string
|
||||
{
|
||||
return $this->recipient;
|
||||
}
|
||||
|
||||
public function setRecipient(string $recipient): self
|
||||
{
|
||||
$this->recipient = $recipient;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSender(): string
|
||||
{
|
||||
return $this->sender;
|
||||
}
|
||||
|
||||
public function setSender(string $sender): self
|
||||
{
|
||||
$this->sender = $sender;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubject(): string
|
||||
{
|
||||
return $this->subject;
|
||||
}
|
||||
|
||||
public function setSubject(string $subject): self
|
||||
{
|
||||
$this->subject = $subject;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function setContent(string $content): self
|
||||
{
|
||||
$this->content = $content;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getToName(): ?string
|
||||
{
|
||||
return $this->toName;
|
||||
}
|
||||
|
||||
public function setToName(?string $toName): self
|
||||
{
|
||||
$this->toName = $toName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFromName(): ?string
|
||||
{
|
||||
return $this->fromName;
|
||||
}
|
||||
|
||||
public function setFromName(?string $fromName): self
|
||||
{
|
||||
$this->fromName = $fromName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientId(): ?int
|
||||
{
|
||||
return $this->clientId;
|
||||
}
|
||||
|
||||
public function setClientId(?int $clientId): self
|
||||
{
|
||||
$this->clientId = $clientId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAdminId(): ?int
|
||||
{
|
||||
return $this->adminId;
|
||||
}
|
||||
|
||||
public function setAdminId(?int $adminId): self
|
||||
{
|
||||
$this->adminId = $adminId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPriority(): ?int
|
||||
{
|
||||
return $this->priority;
|
||||
}
|
||||
|
||||
public function setPriority(?int $priority): self
|
||||
{
|
||||
$this->priority = $priority;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTries(): int
|
||||
{
|
||||
return $this->tries;
|
||||
}
|
||||
|
||||
public function setTries(int $tries): self
|
||||
{
|
||||
$this->tries = $tries;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(string $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Email\Repository;
|
||||
|
||||
use Box\Mod\Email\Entity\ActivityClientEmail;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class ActivityClientEmailRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* Build a QueryBuilder for the per-client email history listing.
|
||||
*
|
||||
* Accepted keys in `$data`:
|
||||
* - `id` (int) exact id match
|
||||
* - `client_id` (int) filter to one client
|
||||
* - `sender` (string) LIKE on sender
|
||||
* - `recipient` (string) LIKE on recipients
|
||||
* - `subject` (string) LIKE on subject
|
||||
* - `search` (string) LIKE on subject, sender, recipients, or content
|
||||
* - `date_from` (string) created_at lower bound
|
||||
* - `date_to` (string) created_at upper bound
|
||||
*/
|
||||
public function getSearchQueryBuilder(array $data = []): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('e')
|
||||
->orderBy('e.id', 'DESC');
|
||||
|
||||
if (!empty($data['id'])) {
|
||||
$qb->andWhere('e.id = :id')
|
||||
->setParameter('id', (int) $data['id']);
|
||||
}
|
||||
|
||||
if (!empty($data['client_id'])) {
|
||||
$qb->andWhere('e.clientId = :client_id')
|
||||
->setParameter('client_id', (int) $data['client_id']);
|
||||
}
|
||||
|
||||
if (!empty($data['sender'])) {
|
||||
$qb->andWhere('e.sender LIKE :sender')
|
||||
->setParameter('sender', '%' . $data['sender'] . '%');
|
||||
}
|
||||
|
||||
if (!empty($data['recipient'])) {
|
||||
$qb->andWhere('e.recipients LIKE :recipient')
|
||||
->setParameter('recipient', '%' . $data['recipient'] . '%');
|
||||
}
|
||||
|
||||
if (!empty($data['subject'])) {
|
||||
$qb->andWhere('e.subject LIKE :subject')
|
||||
->setParameter('subject', '%' . $data['subject'] . '%');
|
||||
}
|
||||
|
||||
if (!empty($data['search'])) {
|
||||
$qb->andWhere('(e.subject LIKE :search OR e.sender LIKE :search OR e.recipients LIKE :search OR e.contentText LIKE :search OR e.contentHtml LIKE :search)')
|
||||
->setParameter('search', '%' . $data['search'] . '%');
|
||||
}
|
||||
|
||||
if (!empty($data['date_from'])) {
|
||||
$qb->andWhere('e.createdAt >= :date_from')
|
||||
->setParameter('date_from', new \DateTime((string) $data['date_from'] . ' 00:00:00'));
|
||||
}
|
||||
|
||||
if (!empty($data['date_to'])) {
|
||||
$qb->andWhere('e.createdAt <= :date_to')
|
||||
->setParameter('date_to', new \DateTime((string) $data['date_to'] . ' 23:59:59'));
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single email owned by the given client.
|
||||
*/
|
||||
public function findOneForClientById(int $clientId, int $id): ?ActivityClientEmail
|
||||
{
|
||||
return $this->findOneBy(['id' => $id, 'clientId' => $clientId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single email by id, throwing if it does not exist.
|
||||
*/
|
||||
public function findOneByIdOrFail(int $id): ActivityClientEmail
|
||||
{
|
||||
$email = $this->find($id);
|
||||
if (!$email instanceof ActivityClientEmail) {
|
||||
throw new \FOSSBilling\Exception('Email not found');
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single email owned by the given client, throwing if it does not exist.
|
||||
*/
|
||||
public function findOneForClientByIdOrFail(int $clientId, int $id): ActivityClientEmail
|
||||
{
|
||||
$email = $this->findOneForClientById($clientId, $id);
|
||||
if (!$email instanceof ActivityClientEmail) {
|
||||
throw new \FOSSBilling\Exception('Email not found');
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ActivityClientEmail[]
|
||||
*/
|
||||
public function findByClientId(int $clientId): array
|
||||
{
|
||||
return $this->findBy(['clientId' => $clientId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every email log row belonging to a client in a single query.
|
||||
*
|
||||
* Returns the number of deleted rows.
|
||||
*/
|
||||
public function deleteByClientId(int $clientId): int
|
||||
{
|
||||
return (int) $this->getEntityManager()->createQueryBuilder()
|
||||
->delete(ActivityClientEmail::class, 'e')
|
||||
->where('e.clientId = :client_id')
|
||||
->setParameter('client_id', $clientId)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Email\Repository;
|
||||
|
||||
use Box\Mod\Email\Entity\QueuedEmail;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class QueuedEmailRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* Build a QueryBuilder for the email queue listing (admin UI).
|
||||
*/
|
||||
public function getSearchQueryBuilder(array $data = []): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('q')
|
||||
->orderBy('q.priority', 'DESC')
|
||||
->addOrderBy('q.id', 'ASC');
|
||||
|
||||
if (!empty($data['status'])) {
|
||||
$qb->andWhere('q.status = :status')
|
||||
->setParameter('status', $data['status']);
|
||||
}
|
||||
|
||||
if (!empty($data['client_id'])) {
|
||||
$qb->andWhere('q.clientId = :client_id')
|
||||
->setParameter('client_id', (int) $data['client_id']);
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the batch of pending queue items the cron should attempt to send.
|
||||
*
|
||||
* Items are ordered by priority (highest first) and by ID (FIFO within a
|
||||
* priority bucket). Includes legacy `unsent`, `pending`, and `failed`
|
||||
* status so that transient failures and pre-migration rows get attempted.
|
||||
*
|
||||
* @return QueuedEmail[]
|
||||
*/
|
||||
public function findDueBatch(int $limit = 50): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('q')
|
||||
->andWhere('q.status IN (:statuses)')
|
||||
->setParameter('statuses', [QueuedEmail::STATUS_UNSENT, QueuedEmail::STATUS_PENDING, QueuedEmail::STATUS_FAILED])
|
||||
->orderBy('q.priority', 'DESC')
|
||||
->addOrderBy('q.id', 'ASC');
|
||||
|
||||
if ($limit > 0) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
+86
-109
@@ -12,8 +12,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Box\Mod\Email;
|
||||
|
||||
use Box\Mod\Email\Entity\ActivityClientEmail;
|
||||
use Box\Mod\Email\Entity\EmailTemplate;
|
||||
use Box\Mod\Email\Entity\QueuedEmail;
|
||||
use Box\Mod\Email\Repository\ActivityClientEmailRepository;
|
||||
use Box\Mod\Email\Repository\EmailTemplateRepository;
|
||||
use Box\Mod\Email\Repository\QueuedEmailRepository;
|
||||
use FOSSBilling\Config;
|
||||
use FOSSBilling\Environment;
|
||||
use FOSSBilling\PaginationOptions;
|
||||
@@ -25,7 +29,9 @@ use Symfony\Component\Finder\Finder;
|
||||
class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
{
|
||||
protected ?\Pimple\Container $di = null;
|
||||
protected ?EmailTemplateRepository $templateRepository = null;
|
||||
protected EmailTemplateRepository $templateRepository;
|
||||
protected ActivityClientEmailRepository $activityClientEmailRepository;
|
||||
protected QueuedEmailRepository $queuedEmailRepository;
|
||||
private readonly Filesystem $filesystem;
|
||||
|
||||
public function __construct()
|
||||
@@ -36,6 +42,9 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
public function setDi(\Pimple\Container $di): void
|
||||
{
|
||||
$this->di = $di;
|
||||
$this->templateRepository = $di['em']->getRepository(EmailTemplate::class);
|
||||
$this->activityClientEmailRepository = $di['em']->getRepository(ActivityClientEmail::class);
|
||||
$this->queuedEmailRepository = $di['em']->getRepository(QueuedEmail::class);
|
||||
}
|
||||
|
||||
public function getDi(): ?\Pimple\Container
|
||||
@@ -45,16 +54,19 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
|
||||
public function getTemplateRepository(): EmailTemplateRepository
|
||||
{
|
||||
if ($this->templateRepository === null) {
|
||||
if ($this->di === null) {
|
||||
throw new \FOSSBilling\Exception('The dependency injection container has not been set.');
|
||||
}
|
||||
$this->templateRepository = $this->di['em']->getRepository(EmailTemplate::class);
|
||||
}
|
||||
|
||||
return $this->templateRepository;
|
||||
}
|
||||
|
||||
public function getActivityClientEmailRepository(): ActivityClientEmailRepository
|
||||
{
|
||||
return $this->activityClientEmailRepository;
|
||||
}
|
||||
|
||||
public function getQueuedEmailRepository(): QueuedEmailRepository
|
||||
{
|
||||
return $this->queuedEmailRepository;
|
||||
}
|
||||
|
||||
public function getModulePermissions(): array
|
||||
{
|
||||
return [
|
||||
@@ -156,48 +168,25 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
return [$query, $bindings];
|
||||
}
|
||||
|
||||
public function findOneForClientById(\Model_Client $client, $id)
|
||||
{
|
||||
$bindings = [
|
||||
':id' => $id,
|
||||
':client_id' => $client->id,
|
||||
];
|
||||
|
||||
$db = $this->di['db'];
|
||||
|
||||
return $db->findOne('ActivityClientEmail', 'id = :id AND client_id = :client_id ORDER BY id DESC', $bindings);
|
||||
}
|
||||
|
||||
public function rmByClient(\Model_Client $client): bool
|
||||
{
|
||||
$models = $this->di['db']->find('ActivityClientEmail', 'client_id = ?', [$client->id]);
|
||||
foreach ($models as $model) {
|
||||
$this->di['db']->trash($model);
|
||||
}
|
||||
$this->getActivityClientEmailRepository()->deleteByClientId((int) $client->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rm(\Model_ActivityClientEmail $email): bool
|
||||
{
|
||||
$db = $this->di['db'];
|
||||
$db->trash($email);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function toApiArray(\Model_ActivityClientEmail $model, $deep = true): array
|
||||
public function toApiArray(ActivityClientEmail $model, $deep = true): array
|
||||
{
|
||||
return [
|
||||
'id' => $model->id,
|
||||
'client_id' => $model->client_id,
|
||||
'sender' => $model->sender,
|
||||
'recipients' => $model->recipients,
|
||||
'subject' => $model->subject,
|
||||
'content_html' => Tools::sanitizeContent($model->content_html ?? ''),
|
||||
'content_text' => $model->content_text,
|
||||
'created_at' => $model->created_at,
|
||||
'updated_at' => $model->updated_at,
|
||||
'id' => $model->getId(),
|
||||
'client_id' => $model->getClientId(),
|
||||
'sender' => $model->getSender(),
|
||||
'recipients' => $model->getRecipients(),
|
||||
'subject' => $model->getSubject(),
|
||||
'content_html' => Tools::sanitizeContent($model->getContentHtml() ?? ''),
|
||||
'content_text' => $model->getContentText(),
|
||||
'created_at' => $model->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $model->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -260,13 +249,14 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
|
||||
// send email to admins
|
||||
if (isset($data['to_admin']) && $data['to_admin'] > 0) {
|
||||
$oneStaff = $this->di['db']->findOne('Admin', 'id=?', [$data['to_admin']]);
|
||||
/** @todo Doctrine: use Admin entity once Staff is migrated */
|
||||
$oneStaff = $this->di['dbal']->fetchAssociative('SELECT id, email, name, signature FROM admin WHERE id = :id', ['id' => $data['to_admin']]);
|
||||
// Convert to array with only safe fields
|
||||
$vars['c'] = [
|
||||
'id' => $oneStaff->id,
|
||||
'email' => $oneStaff->email,
|
||||
'name' => $oneStaff->name,
|
||||
'signature' => $oneStaff->signature,
|
||||
'id' => $oneStaff['id'],
|
||||
'email' => $oneStaff['email'],
|
||||
'name' => $oneStaff['name'],
|
||||
'signature' => $oneStaff['signature'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -307,9 +297,9 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
$sent = $this->sendMail($to, $from, $subject, $content, $to_name, $from_name, null, $staff['id'], $send_now, $throw_exceptions);
|
||||
}
|
||||
} elseif (isset($oneStaff)) {
|
||||
$to = $oneStaff->email;
|
||||
$to_name = $oneStaff->name;
|
||||
$sent = $this->sendMail($to, $from, $subject, $content, $to_name, $from_name, $oneStaff->id, null, $send_now, $throw_exceptions);
|
||||
$to = $oneStaff['email'];
|
||||
$to_name = $oneStaff['name'];
|
||||
$sent = $this->sendMail($to, $from, $subject, $content, $to_name, $from_name, $oneStaff['id'], null, $send_now, $throw_exceptions);
|
||||
} elseif (isset($customer)) {
|
||||
$to = $customer['email'];
|
||||
$to_name = $customer['first_name'] . ' ' . $customer['last_name'];
|
||||
@@ -523,27 +513,26 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
return [$template->getSubject() ?? '', $template->getContent() ?? ''];
|
||||
}
|
||||
|
||||
private function _queue($to, $from, $subject, $content, $to_name = null, $from_name = null, $client_id = null, $admin_id = null)
|
||||
private function _queue($to, $from, $subject, $content, $to_name = null, $from_name = null, $client_id = null, $admin_id = null): QueuedEmail
|
||||
{
|
||||
$db = $this->di['db'];
|
||||
$em = $this->di['em'];
|
||||
|
||||
$queue = $db->dispense('ModEmailQueue');
|
||||
$queue->recipient = $to;
|
||||
$queue->sender = $from;
|
||||
$queue->subject = $subject;
|
||||
$queue->content = $content;
|
||||
$queue->to_name = $to_name;
|
||||
$queue->from_name = $from_name;
|
||||
$queue->client_id = $client_id;
|
||||
$queue->admin_id = $admin_id;
|
||||
$queue->status = 'unsent';
|
||||
$queue->created_at = date('Y-m-d H:i:s');
|
||||
$queue->updated_at = date('Y-m-d H:i:s');
|
||||
$queue->priority = 1;
|
||||
$queue->tries = 0;
|
||||
$queue = new QueuedEmail();
|
||||
$queue->setRecipient((string) $to);
|
||||
$queue->setSender((string) $from);
|
||||
$queue->setSubject((string) $subject);
|
||||
$queue->setContent((string) $content);
|
||||
$queue->setToName($to_name !== null ? (string) $to_name : null);
|
||||
$queue->setFromName($from_name !== null ? (string) $from_name : null);
|
||||
$queue->setClientId($client_id !== null ? (int) $client_id : null);
|
||||
$queue->setAdminId($admin_id !== null ? (int) $admin_id : null);
|
||||
$queue->setStatus(QueuedEmail::STATUS_PENDING);
|
||||
$queue->setPriority(1);
|
||||
$queue->setTries(0);
|
||||
|
||||
try {
|
||||
$db->store($queue);
|
||||
$em->persist($queue);
|
||||
$em->flush();
|
||||
} catch (\Exception $e) {
|
||||
error_log($e->getMessage());
|
||||
}
|
||||
@@ -591,7 +580,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
}
|
||||
}
|
||||
|
||||
public function resend(\Model_ActivityClientEmail $email): bool
|
||||
public function resend(ActivityClientEmail $email): bool
|
||||
{
|
||||
$extensionService = $this->di['mod_service']('extension');
|
||||
if ($extensionService->isExtensionActive('mod', 'demo')) {
|
||||
@@ -608,22 +597,22 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
}
|
||||
|
||||
$clientService = $this->di['mod_service']('client');
|
||||
$customer = $clientService->get(['id' => $email->client_id]);
|
||||
$customer = $clientService->get(['id' => $email->getClientId()]);
|
||||
$customer = $clientService->toApiArray($customer);
|
||||
|
||||
$systemService = $this->di['mod_service']('system');
|
||||
$from_name = $systemService->getParamValue('company_name');
|
||||
|
||||
$this->sendMail($email->recipients, $email->sender, $email->subject, $email->content_html, $customer['first_name'] . ' ' . $customer['last_name'], $from_name, $email->client_id);
|
||||
$this->sendMail($email->getRecipients(), $email->getSender(), $email->getSubject(), $email->getContentHtml(), $customer['first_name'] . ' ' . $customer['last_name'], $from_name, $email->getClientId());
|
||||
|
||||
$this->di['logger']->info('Resent email #%s', $email->id);
|
||||
$this->di['logger']->info('Resent email #%s', $email->getId());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function queueGetSearchQuery($data): array
|
||||
{
|
||||
$query = 'SELECT * FROM mod_email_queue';
|
||||
$query = 'SELECT * FROM email_queue';
|
||||
|
||||
$id = $data['id'] ?? null;
|
||||
$search = $data['search'] ?? null;
|
||||
@@ -823,14 +812,9 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getEmailById($id)
|
||||
public function getEmailById($id): ActivityClientEmail
|
||||
{
|
||||
$model = $this->di['db']->findOne('ActivityClientEmail', 'id = ?', [$id]);
|
||||
if (!$model instanceof \Model_ActivityClientEmail) {
|
||||
throw new \FOSSBilling\Exception('Email not found');
|
||||
}
|
||||
|
||||
return $model;
|
||||
return $this->getActivityClientEmailRepository()->findOneByIdOrFail((int) $id);
|
||||
}
|
||||
|
||||
public function templateCreate($actionCode, $subject, $content, $enabled = 0, $category = null): EmailTemplate
|
||||
@@ -1016,34 +1000,26 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
|
||||
$start = time();
|
||||
|
||||
$query = 'ORDER BY created_at ASC';
|
||||
if ($sendPerCron) {
|
||||
$query .= ' LIMIT ' . intval($sendPerCron);
|
||||
$mailQueue = $this->di['db']->findAll('mod_email_queue', $query);
|
||||
} else {
|
||||
$mailQueue = $this->di['db']->findAll('mod_email_queue', $query);
|
||||
}
|
||||
$mailQueue = $this->getQueuedEmailRepository()->findDueBatch((int) $sendPerCron);
|
||||
|
||||
foreach ($mailQueue as $email) {
|
||||
$mailModel = new \Model_ModEmailQueue();
|
||||
$mailModel->loadBean($email);
|
||||
$this->_sendFromQueue($mailModel);
|
||||
$this->_sendFromQueue($email);
|
||||
if ($time_limit && time() - $start > $time_limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function _sendFromQueue(\Model_ModEmailQueue $queue, bool $throw_exceptions = false): bool
|
||||
private function _sendFromQueue(QueuedEmail $queue, bool $throw_exceptions = false): bool
|
||||
{
|
||||
$extensionService = $this->di['mod_service']('extension');
|
||||
if ($extensionService->isExtensionActive('mod', 'demo')) {
|
||||
return false;
|
||||
}
|
||||
$queue->status = 'sending';
|
||||
$this->di['db']->store($queue);
|
||||
$queue->setStatus('sending');
|
||||
$this->di['em']->flush();
|
||||
|
||||
$queue->content .= PHP_EOL;
|
||||
$queue->setContent($queue->getContent() . PHP_EOL);
|
||||
|
||||
$mod = $this->di['mod']('email');
|
||||
$settings = $mod->getConfig();
|
||||
@@ -1051,16 +1027,16 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
|
||||
$transport = $settings['mailer'] ?? 'sendmail';
|
||||
$sender = [
|
||||
'email' => $queue->sender,
|
||||
'name' => $queue->from_name,
|
||||
'email' => $queue->getSender(),
|
||||
'name' => $queue->getFromName(),
|
||||
];
|
||||
$recipient = [
|
||||
'email' => $queue->recipient,
|
||||
'name' => $queue->to_name,
|
||||
'email' => $queue->getRecipient(),
|
||||
'name' => $queue->getToName(),
|
||||
];
|
||||
|
||||
try {
|
||||
$mail = new \FOSSBilling\Mail($sender, $recipient, $queue->subject, $queue->content, $transport, $settings['custom_dsn'] ?? null);
|
||||
$mail = new \FOSSBilling\Mail($sender, $recipient, $queue->getSubject(), $queue->getContent(), $transport, $settings['custom_dsn'] ?? null);
|
||||
if (!empty($settings['reply_to'])) {
|
||||
if (filter_var($settings['reply_to'], FILTER_VALIDATE_EMAIL)) {
|
||||
$mail->addReplyTo($settings['reply_to']);
|
||||
@@ -1080,11 +1056,12 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
// It sent without causing an exception (error), so we are safe to log it now
|
||||
if ($log) {
|
||||
$activityService = $this->di['mod_service']('activity');
|
||||
$activityService->logEmail($queue->subject, $queue->client_id, $queue->sender, $queue->recipient, $queue->content);
|
||||
$activityService->logEmail($queue->getSubject(), $queue->getClientId(), $queue->getSender(), $queue->getRecipient(), $queue->getContent());
|
||||
}
|
||||
|
||||
try {
|
||||
$this->di['db']->trash($queue);
|
||||
$this->di['em']->remove($queue);
|
||||
$this->di['em']->flush();
|
||||
} catch (\Exception $e) {
|
||||
$this->di['logger']->setChannel('email')->error($e->getMessage());
|
||||
}
|
||||
@@ -1092,23 +1069,23 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
$message = $e->getMessage();
|
||||
$this->di['logger']->setChannel('email')->error($e->getMessage());
|
||||
|
||||
if ($queue->priority) {
|
||||
--$queue->priority;
|
||||
if ($queue->getPriority()) {
|
||||
$queue->setPriority($queue->getPriority() - 1);
|
||||
}
|
||||
|
||||
$queue->status = 'unsent';
|
||||
++$queue->tries;
|
||||
$queue->updated_at = date('Y-m-d H:i:s');
|
||||
$this->di['db']->store($queue);
|
||||
$queue->setStatus(QueuedEmail::STATUS_FAILED);
|
||||
$queue->setTries($queue->getTries() + 1);
|
||||
$this->di['em']->flush();
|
||||
|
||||
$maxTries = $settings['cancel_after'] ?? 5;
|
||||
if ($queue->tries > $maxTries) {
|
||||
if ($queue->getTries() > $maxTries) {
|
||||
// The email failed to send after the max number of tries. This might be because of a server error, so let's be sure to log it which gives the client the ability to resend it.
|
||||
if ($log) {
|
||||
$activityService = $this->di['mod_service']('activity');
|
||||
$activityService->logEmail($queue->subject, $queue->client_id, $queue->sender, $queue->recipient, $queue->content);
|
||||
$activityService->logEmail($queue->getSubject(), $queue->getClientId(), $queue->getSender(), $queue->getRecipient(), $queue->getContent());
|
||||
}
|
||||
$this->di['db']->trash($queue);
|
||||
$this->di['em']->remove($queue);
|
||||
$this->di['em']->flush();
|
||||
}
|
||||
|
||||
if ($throw_exceptions) {
|
||||
|
||||
@@ -17,7 +17,6 @@ use function Tests\Helpers\moduleService;
|
||||
|
||||
test('email get list', function (): void {
|
||||
$adminApi = new Box\Mod\Email\Api\Admin();
|
||||
$emailService = new Box\Mod\Email\Service();
|
||||
|
||||
$willReturn = [
|
||||
'list' => [
|
||||
@@ -27,18 +26,22 @@ test('email get list', function (): void {
|
||||
|
||||
$pager = Mockery::mock(FOSSBilling\Pagination::class)->makePartial();
|
||||
$pager
|
||||
->shouldReceive('getPaginatedResultSet')
|
||||
->atLeast()->once()
|
||||
->andReturn($willReturn);
|
||||
->shouldReceive('paginateDoctrineQuery')
|
||||
->atLeast()->once()
|
||||
->andReturn($willReturn);
|
||||
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
|
||||
$repo->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
|
||||
|
||||
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
|
||||
$di = container();
|
||||
$di['pager'] = $pager;
|
||||
|
||||
$adminApi->setDi($di);
|
||||
$emailService->setDi($di);
|
||||
|
||||
$service = $emailService;
|
||||
$adminApi->setService($service);
|
||||
$adminApi->setService($emailService);
|
||||
|
||||
$result = $adminApi->email_get_list([]);
|
||||
expect($result)->toBeArray();
|
||||
@@ -63,29 +66,18 @@ test('email get', function (): void {
|
||||
$created = date('Y-m-d H:i:s', time() - 86400);
|
||||
$updated = date('Y-m-d H:i:s');
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model->id = $id;
|
||||
$model->client_id = $client_id;
|
||||
$model->sender = $sender;
|
||||
$model->recipients = $recipients;
|
||||
$model->subject = $subject;
|
||||
$model->content_html = $content_html;
|
||||
$model->content_text = $content_text;
|
||||
$model->created_at = $created;
|
||||
$model->updated_at = $updated;
|
||||
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
|
||||
\Tests\Helpers\setEntityId($model, $id);
|
||||
$model->setClientId($client_id);
|
||||
$model->setSender($sender);
|
||||
$model->setRecipients($recipients);
|
||||
$model->setSubject($subject);
|
||||
$model->setContentHtml($content_html);
|
||||
$model->setContentText($content_text);
|
||||
$model->setCreatedAt(new DateTime('-1 day'));
|
||||
$model->setUpdatedAt(new DateTime());
|
||||
|
||||
$expected = [
|
||||
'id' => $id,
|
||||
'client_id' => $client_id,
|
||||
'sender' => $sender,
|
||||
'recipients' => $recipients,
|
||||
'subject' => $subject,
|
||||
'content_html' => $content_html,
|
||||
'content_text' => $content_text,
|
||||
'created_at' => $created,
|
||||
'updated_at' => $updated,
|
||||
];
|
||||
$expected = $model->toApiArray();
|
||||
|
||||
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$service
|
||||
@@ -119,10 +111,6 @@ test('send', function (): void {
|
||||
'content' => 'Content',
|
||||
];
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model->id = 1;
|
||||
|
||||
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$emailService
|
||||
->shouldReceive('sendMail')
|
||||
@@ -146,25 +134,24 @@ test('resend', function (): void {
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model->id = 1;
|
||||
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
|
||||
\Tests\Helpers\setEntityId($model, 1);
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db
|
||||
->shouldReceive('findOne')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneByIdOrFail')
|
||||
->atLeast()->once()
|
||||
->with(1)
|
||||
->andReturn($model);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $db;
|
||||
$adminApi->setDi($di);
|
||||
|
||||
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
$emailService
|
||||
->shouldReceive('resend')
|
||||
->atLeast()->once()
|
||||
->andReturn(true);
|
||||
->shouldReceive('resend')
|
||||
->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$adminApi->setService($emailService);
|
||||
|
||||
@@ -180,16 +167,17 @@ test('resend exception email not found', function (): void {
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db
|
||||
->shouldReceive('findOne')
|
||||
->atLeast()->once()
|
||||
->andReturn(null);
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneByIdOrFail')
|
||||
->andThrow(new FOSSBilling\Exception('Email not found'));
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $db;
|
||||
$adminApi->setDi($di);
|
||||
|
||||
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
$adminApi->setService($emailService);
|
||||
|
||||
$this->expectException(FOSSBilling\Exception::class);
|
||||
$this->expectExceptionMessage('Email not found');
|
||||
$adminApi->email_resend($data);
|
||||
@@ -202,16 +190,17 @@ test('delete exception email not found', function (): void {
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db
|
||||
->shouldReceive('findOne')
|
||||
->atLeast()->once()
|
||||
->andReturn(null);
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneByIdOrFail')
|
||||
->andThrow(new FOSSBilling\Exception('Email not found'));
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $db;
|
||||
$adminApi->setDi($di);
|
||||
|
||||
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
$adminApi->setService($emailService);
|
||||
|
||||
$this->expectException(FOSSBilling\Exception::class);
|
||||
$this->expectExceptionMessage('Email not found');
|
||||
$adminApi->email_delete($data);
|
||||
@@ -219,33 +208,34 @@ test('delete exception email not found', function (): void {
|
||||
|
||||
test('email delete', function (): void {
|
||||
$adminApi = new Box\Mod\Email\Api\Admin();
|
||||
$emailService = new Box\Mod\Email\Service();
|
||||
|
||||
$data = [
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model->id = 1;
|
||||
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
|
||||
\Tests\Helpers\setEntityId($model, 1);
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db
|
||||
->shouldReceive('findOne')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$db
|
||||
->shouldReceive('trash')
|
||||
->atLeast()->once()
|
||||
->andReturn(true);
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em->shouldReceive('remove')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$loggerStub = $this->createStub('\Box_Log');
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $db;
|
||||
$di['em'] = $em;
|
||||
$di['logger'] = $loggerStub;
|
||||
$adminApi->setDi($di);
|
||||
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneByIdOrFail')
|
||||
->atLeast()->once()
|
||||
->with(1)
|
||||
->andReturn($model);
|
||||
|
||||
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
|
||||
$adminApi->setService($emailService);
|
||||
|
||||
$result = $adminApi->email_delete($data);
|
||||
|
||||
@@ -14,7 +14,6 @@ use function Tests\Helpers\container;
|
||||
|
||||
test('get list', function (): void {
|
||||
$clientApi = new Box\Mod\Email\Api\Client();
|
||||
$emailService = new Box\Mod\Email\Service();
|
||||
|
||||
$willReturn = [
|
||||
'list' => [
|
||||
@@ -23,17 +22,21 @@ test('get list', function (): void {
|
||||
];
|
||||
$pager = Mockery::mock(FOSSBilling\Pagination::class)->makePartial();
|
||||
$pager
|
||||
->shouldReceive('getPaginatedResultSet')
|
||||
->atLeast()->once()
|
||||
->andReturn($willReturn);
|
||||
->shouldReceive('paginateDoctrineQuery')
|
||||
->atLeast()->once()
|
||||
->andReturn($willReturn);
|
||||
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
|
||||
$repo->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
|
||||
|
||||
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
|
||||
$di = container();
|
||||
$di['pager'] = $pager;
|
||||
|
||||
$clientApi->setDi($di);
|
||||
$emailService->setDi($di);
|
||||
|
||||
$service = $emailService;
|
||||
$clientApi->setService($service);
|
||||
|
||||
$client = new Model_Client();
|
||||
@@ -51,17 +54,19 @@ test('get list', function (): void {
|
||||
test('get', function (): void {
|
||||
$clientApi = new Box\Mod\Email\Api\Client();
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
|
||||
\Tests\Helpers\setEntityId($model, 1);
|
||||
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneForClientByIdOrFail')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
|
||||
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$service
|
||||
->shouldReceive('findOneForClientById')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$service
|
||||
->shouldReceive('toApiArray')
|
||||
->atLeast()->once()
|
||||
->andReturn([]);
|
||||
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
$service->shouldReceive('toApiArray')
|
||||
->atLeast()->once()
|
||||
->andReturn([]);
|
||||
$clientApi->setService($service);
|
||||
|
||||
$di = container();
|
||||
@@ -79,11 +84,12 @@ test('get', function (): void {
|
||||
test('get not found exception', function (): void {
|
||||
$clientApi = new Box\Mod\Email\Api\Client();
|
||||
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneForClientByIdOrFail')
|
||||
->andThrow(new FOSSBilling\Exception('Email not found'));
|
||||
|
||||
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$service
|
||||
->shouldReceive('findOneForClientById')
|
||||
->atLeast()->once()
|
||||
->andReturn(false);
|
||||
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
|
||||
$client = new Model_Client();
|
||||
$client->loadBean(new Tests\Helpers\DummyBean());
|
||||
@@ -103,18 +109,19 @@ test('get not found exception', function (): void {
|
||||
test('resend', function (): void {
|
||||
$clientApi = new Box\Mod\Email\Api\Client();
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
|
||||
\Tests\Helpers\setEntityId($model, 1);
|
||||
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneForClientByIdOrFail')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
|
||||
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$service
|
||||
->shouldReceive('findOneForClientById')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$service
|
||||
->shouldReceive('resend')
|
||||
->atLeast()->once()
|
||||
->andReturn(true);
|
||||
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
$service->shouldReceive('resend')
|
||||
->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$client = new Model_Client();
|
||||
$client->loadBean(new Tests\Helpers\DummyBean());
|
||||
@@ -133,11 +140,12 @@ test('resend', function (): void {
|
||||
test('resend not found exception', function (): void {
|
||||
$clientApi = new Box\Mod\Email\Api\Client();
|
||||
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneForClientByIdOrFail')
|
||||
->andThrow(new FOSSBilling\Exception('Email not found'));
|
||||
|
||||
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$service
|
||||
->shouldReceive('findOneForClientById')
|
||||
->atLeast()->once()
|
||||
->andReturn(false);
|
||||
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
|
||||
$client = new Model_Client();
|
||||
$client->loadBean(new Tests\Helpers\DummyBean());
|
||||
@@ -160,17 +168,16 @@ test('delete', function (): void {
|
||||
|
||||
$di = container();
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
|
||||
\Tests\Helpers\setEntityId($model, 1);
|
||||
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneForClientByIdOrFail')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
|
||||
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$service
|
||||
->shouldReceive('findOneForClientById')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$service
|
||||
->shouldReceive('rm')
|
||||
->atLeast()->once()
|
||||
->andReturn(true);
|
||||
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
|
||||
$client = new Model_Client();
|
||||
$client->loadBean(new Tests\Helpers\DummyBean());
|
||||
@@ -188,11 +195,12 @@ test('delete', function (): void {
|
||||
test('delete not found exception', function (): void {
|
||||
$clientApi = new Box\Mod\Email\Api\Client();
|
||||
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneForClientByIdOrFail')
|
||||
->andThrow(new FOSSBilling\Exception('Email not found'));
|
||||
|
||||
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
|
||||
$service
|
||||
->shouldReceive('findOneForClientById')
|
||||
->atLeast()->once()
|
||||
->andReturn(false);
|
||||
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
|
||||
|
||||
$client = new Model_Client();
|
||||
$client->loadBean(new Tests\Helpers\DummyBean());
|
||||
|
||||
@@ -18,9 +18,6 @@ test('dependency injection container is properly set and retrieved', function ()
|
||||
$controller = new Admin();
|
||||
|
||||
$di = container();
|
||||
$dbMock = Mockery::mock('Box_Database');
|
||||
|
||||
$di['db'] = $dbMock;
|
||||
$controller->setDi($di);
|
||||
$result = $controller->getDi();
|
||||
expect($result)->toEqual($di);
|
||||
|
||||
@@ -62,6 +62,37 @@ function emailTemplate(string $actionCode = '', ?int $id = null, array $data = [
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a partial Mockery EntityManager that returns the correct repository mock
|
||||
* for each Email entity class requested. Used because the service eagerly fetches
|
||||
* all repositories inside setDi().
|
||||
*
|
||||
* @return Doctrine\ORM\EntityManagerInterface&Mockery\MockInterface
|
||||
*/
|
||||
function emailBuildEm(
|
||||
?Box\Mod\Email\Repository\ActivityClientEmailRepository $activityRepo = null,
|
||||
?Box\Mod\Email\Repository\EmailTemplateRepository $templateRepo = null,
|
||||
?Box\Mod\Email\Repository\QueuedEmailRepository $queueRepo = null,
|
||||
bool $ignoreMissing = true,
|
||||
) {
|
||||
$activityRepo ??= Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class)->shouldIgnoreMissing();
|
||||
$templateRepo ??= Mockery::mock(Box\Mod\Email\Repository\EmailTemplateRepository::class)->shouldIgnoreMissing();
|
||||
$queueRepo ??= Mockery::mock(Box\Mod\Email\Repository\QueuedEmailRepository::class)->shouldIgnoreMissing();
|
||||
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
if ($ignoreMissing) {
|
||||
$em->shouldIgnoreMissing();
|
||||
}
|
||||
$em->shouldReceive('getRepository')->andReturnUsing(static fn (string $class): object => match ($class) {
|
||||
Box\Mod\Email\Entity\ActivityClientEmail::class => $activityRepo,
|
||||
EmailTemplate::class => $templateRepo,
|
||||
Box\Mod\Email\Entity\QueuedEmail::class => $queueRepo,
|
||||
default => $activityRepo,
|
||||
});
|
||||
|
||||
return $em;
|
||||
}
|
||||
|
||||
test('di returns dependency injection container', function (): void {
|
||||
$service = new Box\Mod\Email\Service();
|
||||
|
||||
@@ -132,54 +163,17 @@ test('getSearchQuery returns query and bindings', function (array $data, string
|
||||
expect($result[1])->toBe($bindings);
|
||||
})->with('getSearchQueryProvider');
|
||||
|
||||
test('findOneForClientById returns email for client', function (): void {
|
||||
$service = new Box\Mod\Email\Service();
|
||||
$di = container();
|
||||
$id = 5;
|
||||
$client_id = 1;
|
||||
|
||||
$activityEmail = new Model_ActivityClientEmail();
|
||||
$activityEmail->loadBean(new Tests\Helpers\DummyBean());
|
||||
$activityEmail->client_id = $client_id;
|
||||
$activityEmail->id = $id;
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('findOne')
|
||||
->atLeast()->once()
|
||||
->andReturn($activityEmail);
|
||||
|
||||
$di['db'] = $db;
|
||||
$service->setDi($di);
|
||||
|
||||
$client = new Model_Client();
|
||||
$client->loadBean(new Tests\Helpers\DummyBean());
|
||||
$client->id = $client_id;
|
||||
|
||||
$result = $service->findOneForClientById($client, $id);
|
||||
|
||||
expect($result)->toBeInstanceOf('Model_ActivityClientEmail');
|
||||
expect($result->id)->not->toBeNull();
|
||||
expect($result->id)->toBe($activityEmail->id);
|
||||
expect($result->client_id)->toBe($activityEmail->client_id);
|
||||
});
|
||||
|
||||
test('rmByClient removes emails for client', function (): void {
|
||||
$service = new Box\Mod\Email\Service();
|
||||
$di = container();
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('deleteByClientId')
|
||||
->once()
|
||||
->with(1)
|
||||
->andReturn(3);
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('find')
|
||||
->atLeast()->once()
|
||||
->andReturn([$model]);
|
||||
|
||||
$db->shouldReceive('trash')
|
||||
->atLeast()->once()
|
||||
->andReturn(null);
|
||||
|
||||
$di['db'] = $db;
|
||||
$di['em'] = emailBuildEm($repo);
|
||||
$service->setDi($di);
|
||||
|
||||
$client = new Model_Client();
|
||||
@@ -190,26 +184,6 @@ test('rmByClient removes emails for client', function (): void {
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
test('rm removes email', function (): void {
|
||||
$service = new Box\Mod\Email\Service();
|
||||
$di = container();
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('trash')
|
||||
->atLeast()->once()
|
||||
->andReturn(null);
|
||||
|
||||
$di['db'] = $db;
|
||||
$service->setDi($di);
|
||||
|
||||
$email = new Model_ActivityClientEmail();
|
||||
$email->loadBean(new Tests\Helpers\DummyBean());
|
||||
$email->id = 1;
|
||||
|
||||
$result = $service->rm($email);
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
test('toApiArray returns API array for email', function (): void {
|
||||
$service = new Box\Mod\Email\Service();
|
||||
|
||||
@@ -220,20 +194,19 @@ test('toApiArray returns API array for email', function (): void {
|
||||
$subject = 'Subject';
|
||||
$content_html = 'HTML';
|
||||
$content_text = 'TEXT';
|
||||
$created = date('Y-m-d H:i:s', time() - 86400);
|
||||
$updated = date('Y-m-d H:i:s');
|
||||
$created = new DateTime('-1 day');
|
||||
$updated = new DateTime();
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model->id = $id;
|
||||
$model->client_id = $client_id;
|
||||
$model->sender = $sender;
|
||||
$model->recipients = $recipients;
|
||||
$model->subject = $subject;
|
||||
$model->content_html = $content_html;
|
||||
$model->content_text = $content_text;
|
||||
$model->created_at = $created;
|
||||
$model->updated_at = $updated;
|
||||
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
|
||||
\Tests\Helpers\setEntityId($model, $id);
|
||||
$model->setClientId($client_id);
|
||||
$model->setSender($sender);
|
||||
$model->setRecipients($recipients);
|
||||
$model->setSubject($subject);
|
||||
$model->setContentHtml($content_html);
|
||||
$model->setContentText($content_text);
|
||||
$model->setCreatedAt($created);
|
||||
$model->setUpdatedAt($updated);
|
||||
|
||||
$expected = [
|
||||
'id' => $id,
|
||||
@@ -243,8 +216,8 @@ test('toApiArray returns API array for email', function (): void {
|
||||
'subject' => $subject,
|
||||
'content_html' => $content_html,
|
||||
'content_text' => $content_text,
|
||||
'created_at' => $created,
|
||||
'updated_at' => $updated,
|
||||
'created_at' => $created->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $updated->format('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$result = $service->toApiArray($model);
|
||||
@@ -261,7 +234,7 @@ test('setVars encrypts and sets variables', function (): void {
|
||||
->atLeast()->once()
|
||||
->andReturn('encrypted-vars');
|
||||
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em = emailBuildEm();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di['em'] = $em;
|
||||
@@ -311,22 +284,18 @@ test('sendTemplate returns false when template does not exist', function (): voi
|
||||
|
||||
$emailTemplate = emailTemplate();
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('findOne')
|
||||
->byDefault()
|
||||
->andReturn(null);
|
||||
$db->shouldReceive('dispense')
|
||||
->byDefault()
|
||||
->andReturn($emailTemplate);
|
||||
$db->shouldReceive('store')
|
||||
->byDefault()
|
||||
->andReturn(1);
|
||||
$templateRepo = Mockery::mock(Box\Mod\Email\Repository\EmailTemplateRepository::class);
|
||||
$templateRepo->shouldReceive('findOneByActionCode')->andReturn(null);
|
||||
|
||||
$em = emailBuildEm(null, $templateRepo);
|
||||
$em->shouldReceive('persist')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$cryptMock = Mockery::mock('\Box_Crypt');
|
||||
$cryptMock->shouldReceive('encrypt')
|
||||
->atLeast()->once();
|
||||
|
||||
$di['db'] = $db;
|
||||
$di['em'] = $em;
|
||||
$di['crypt'] = $cryptMock;
|
||||
$di['api_admin'] = function () use ($di) {
|
||||
$api = new FOSSBilling\Api\Proxy(new Model_Admin());
|
||||
@@ -359,19 +328,12 @@ test('sendTemplate sends email when template exists', function (): void {
|
||||
|
||||
$emailTemplate = emailTemplate(data: ['enabled' => true]);
|
||||
|
||||
$queueModel = new Model_ModEmailQueue();
|
||||
$queueModel->loadBean(new Tests\Helpers\DummyBean());
|
||||
$templateRepo = Mockery::mock(Box\Mod\Email\Repository\EmailTemplateRepository::class);
|
||||
$templateRepo->shouldReceive('findOneByActionCode')->andReturn($emailTemplate);
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('findOne')
|
||||
->byDefault()
|
||||
->andReturn($emailTemplate);
|
||||
$db->shouldReceive('store')
|
||||
->atLeast()->once()
|
||||
->andReturn(1);
|
||||
$db->shouldReceive('dispense')
|
||||
->atLeast()->once()
|
||||
->andReturn($queueModel);
|
||||
$em = emailBuildEm(null, $templateRepo);
|
||||
$em->shouldReceive('persist')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$systemService = Mockery::mock(Box\Mod\System\Service::class);
|
||||
$systemService->shouldReceive('getParamValue')
|
||||
@@ -405,7 +367,7 @@ test('sendTemplate sends email when template exists', function (): void {
|
||||
'from_email' => 'test@test.com',
|
||||
]);
|
||||
|
||||
$di['db'] = $db;
|
||||
$di['em'] = $em;
|
||||
$di['crypt'] = $cryptMock;
|
||||
$di['twig'] = $twigStub;
|
||||
$di['mod'] = $di->protect(fn () => $modMock);
|
||||
@@ -453,19 +415,10 @@ test('sendTemplate handles to_staff and to_client options', function (array $dat
|
||||
|
||||
$emailTemplate = emailTemplate(data: ['enabled' => true]);
|
||||
|
||||
$queueModel = new Model_ModEmailQueue();
|
||||
$queueModel->loadBean(new Tests\Helpers\DummyBean());
|
||||
$templateRepo = Mockery::mock(Box\Mod\Email\Repository\EmailTemplateRepository::class);
|
||||
$templateRepo->shouldReceive('findOneByActionCode')->andReturn($emailTemplate);
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('findOne')
|
||||
->byDefault()
|
||||
->andReturn(null);
|
||||
$db->shouldReceive('dispense')
|
||||
->atLeast()->once()
|
||||
->andReturnUsing(fn ($type): EmailTemplate|\Model_ModEmailQueue => $type === 'EmailTemplate' ? $emailTemplate : $queueModel);
|
||||
$db->shouldReceive('store')
|
||||
->atLeast()->once()
|
||||
->andReturn(1);
|
||||
$em = emailBuildEm(null, $templateRepo);
|
||||
|
||||
$system = Mockery::mock(Box\Mod\System\Service::class);
|
||||
$system->shouldReceive('getParamValue')
|
||||
@@ -542,7 +495,6 @@ test('sendTemplate handles to_staff and to_client options', function (array $dat
|
||||
]);
|
||||
|
||||
$di['mod'] = $di->protect(fn () => $modMock);
|
||||
$di['db'] = $db;
|
||||
$di['twig'] = $twigStub;
|
||||
$di['crypt'] = $cryptMock;
|
||||
$di['mod_service'] = $di->protect(moduleService([
|
||||
@@ -587,14 +539,14 @@ test('resend resends email', function (): void {
|
||||
|
||||
$service->setDi($di);
|
||||
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model->client_id = 1;
|
||||
$model->sender = 'sender@exemple.com';
|
||||
$model->recipients = 'recipient@example.com';
|
||||
$model->subject = 'Email Title';
|
||||
$model->content_html = '<b>Content</b>';
|
||||
$model->content_text = 'Content';
|
||||
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
|
||||
\Tests\Helpers\setEntityId($model, 1);
|
||||
$model->setClientId(1);
|
||||
$model->setSender('sender@exemple.com');
|
||||
$model->setRecipients('recipient@example.com');
|
||||
$model->setSubject('Email Title');
|
||||
$model->setContentHtml('<b>Content</b>');
|
||||
$model->setContentText('Content');
|
||||
|
||||
$result = $service->resend($model);
|
||||
|
||||
@@ -717,10 +669,6 @@ test('updateTemplate updates template', function (array $data, string $templateR
|
||||
|
||||
$emailService = new Box\Mod\Email\Service();
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('store')
|
||||
->byDefault();
|
||||
|
||||
$loggerStub = new Tests\Helpers\TestLogger();
|
||||
|
||||
$cryptMock = Mockery::mock('\Box_Crypt');
|
||||
@@ -731,7 +679,6 @@ test('updateTemplate updates template', function (array $data, string $templateR
|
||||
$twigStub = Mockery::mock(Twig\Environment::class);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $db;
|
||||
$di['logger'] = $loggerStub;
|
||||
$di['crypt'] = $cryptMock;
|
||||
$di['config'] = $configMock;
|
||||
@@ -759,44 +706,43 @@ test('getEmailById returns email by ID', function (): void {
|
||||
$service = new Box\Mod\Email\Service();
|
||||
|
||||
$id = 1;
|
||||
$model = new Model_ActivityClientEmail();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model->id = $id;
|
||||
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
|
||||
\Tests\Helpers\setEntityId($model, $id);
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('findOne')
|
||||
->atLeast()->once()
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneByIdOrFail')
|
||||
->once()
|
||||
->with($id)
|
||||
->andReturn($model);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $db;
|
||||
$di['em'] = emailBuildEm($repo);
|
||||
$service->setDi($di);
|
||||
|
||||
$result = $service->getEmailById($id);
|
||||
|
||||
expect($result->id)->toBe($id);
|
||||
expect($result->getId())->toBe($id);
|
||||
});
|
||||
|
||||
test('getEmailById throws exception when email not found', function (): void {
|
||||
$service = new Box\Mod\Email\Service();
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('findOne')
|
||||
->atLeast()->once()
|
||||
->andReturn(false);
|
||||
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
|
||||
$repo->shouldReceive('findOneByIdOrFail')
|
||||
->andThrow(new FOSSBilling\Exception('Email not found'));
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $db;
|
||||
$di['em'] = emailBuildEm($repo);
|
||||
$service->setDi($di);
|
||||
|
||||
expect(fn () => $service->getEmailById(5))
|
||||
expect(fn (): Box\Mod\Email\Entity\ActivityClientEmail => $service->getEmailById(5))
|
||||
->toThrow(FOSSBilling\Exception::class);
|
||||
});
|
||||
|
||||
test('templateCreate creates new template', function (): void {
|
||||
$service = new Box\Mod\Email\Service();
|
||||
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em = emailBuildEm();
|
||||
$em->shouldReceive('persist')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
@@ -845,24 +791,26 @@ test('templateBatchEnable enables all templates', function (): void {
|
||||
test('batchSend processes email queue', function (): void {
|
||||
$service = new Box\Mod\Email\Service();
|
||||
|
||||
$queueModel = new Tests\Helpers\DummyBean();
|
||||
$queueModel->priority = 10;
|
||||
$queueModel->tries = 10;
|
||||
$queueModel->subject = 'subject';
|
||||
$queueModel->client_id = 1;
|
||||
$queueModel->sender = 'sender@example.com';
|
||||
$queueModel->recipient = 'receiver@example.com';
|
||||
$queueModel->content = 'content';
|
||||
$queueModel->from_name = 'From Name';
|
||||
$queueModel->to_name = 'To Name';
|
||||
$queueModel = new Box\Mod\Email\Entity\QueuedEmail();
|
||||
\Tests\Helpers\setEntityId($queueModel, 1);
|
||||
$queueModel->setPriority(10);
|
||||
$queueModel->setTries(10);
|
||||
$queueModel->setSubject('subject');
|
||||
$queueModel->setClientId(1);
|
||||
$queueModel->setSender('sender@example.com');
|
||||
$queueModel->setRecipient('receiver@example.com');
|
||||
$queueModel->setContent('content');
|
||||
$queueModel->setFromName('From Name');
|
||||
$queueModel->setToName('To Name');
|
||||
|
||||
$db = Mockery::mock('Box_Database');
|
||||
$db->shouldReceive('findAll')
|
||||
$queueRepo = Mockery::mock(Box\Mod\Email\Repository\QueuedEmailRepository::class);
|
||||
$queueRepo->shouldReceive('findDueBatch')
|
||||
->once()
|
||||
->with(0)
|
||||
->andReturn([$queueModel]);
|
||||
$db->shouldReceive('store')
|
||||
->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$em = emailBuildEm(null, null, $queueRepo);
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
$modMock->shouldReceive('getConfig')
|
||||
@@ -879,7 +827,7 @@ test('batchSend processes email queue', function (): void {
|
||||
->andReturn($isExtensionActiveReturn);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $db;
|
||||
$di['em'] = $em;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
$di['mod_service'] = $di->protect(function ($name) use ($extension) {
|
||||
if ($name == 'extension') {
|
||||
@@ -896,20 +844,12 @@ test('batchSend processes email queue', function (): void {
|
||||
});
|
||||
|
||||
test('sendMail queues email for sending', function (): void {
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
|
||||
$queueEmail = new Model_ModEmailQueue();
|
||||
$queueEmail->loadBean(new Tests\Helpers\DummyBean());
|
||||
$dbMock->shouldReceive('dispense')
|
||||
->atLeast()->once()
|
||||
->with('ModEmailQueue')
|
||||
->andReturn($queueEmail);
|
||||
|
||||
$dbMock->shouldReceive('store')
|
||||
->atLeast()->once();
|
||||
$em = emailBuildEm();
|
||||
$em->shouldReceive('persist')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
$modMock = Mockery::mock('\stdClass');
|
||||
@@ -984,7 +924,7 @@ test('validateAllTemplates reports invalid templates', function (): void {
|
||||
return $template;
|
||||
});
|
||||
|
||||
$emMock = Mockery::mock();
|
||||
$emMock = emailBuildEm();
|
||||
$emMock->shouldReceive('flush')->once();
|
||||
|
||||
$di['mod_service'] = $di->protect(function ($name) use ($systemMock) {
|
||||
@@ -1028,7 +968,7 @@ test('validateAllTemplates clears previous errors on valid templates', function
|
||||
->twice()
|
||||
->andReturnArg(0);
|
||||
|
||||
$emMock = Mockery::mock();
|
||||
$emMock = emailBuildEm();
|
||||
$emMock->shouldReceive('flush')->once();
|
||||
|
||||
$di['mod_service'] = $di->protect(function ($name) use ($systemMock) {
|
||||
@@ -1081,7 +1021,7 @@ test('validateAllTemplates renders templates with stored vars to enforce sandbox
|
||||
->with('Hello {{ name }}', Mockery::any())
|
||||
->never();
|
||||
|
||||
$emMock = Mockery::mock();
|
||||
$emMock = emailBuildEm();
|
||||
$emMock->shouldReceive('flush')->once();
|
||||
|
||||
$di['crypt'] = $cryptMock;
|
||||
@@ -1119,7 +1059,7 @@ test('templateCreate validates subject and content', function (): void {
|
||||
->withArgs(fn ($tpl): bool => str_contains((string) $tpl, 'valid content'))
|
||||
->andReturn('rendered');
|
||||
|
||||
$emMock = Mockery::mock();
|
||||
$emMock = emailBuildEm();
|
||||
$emMock->shouldReceive('persist')->once();
|
||||
$emMock->shouldReceive('flush')->once();
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Box\Mod\Extension\Api;
|
||||
|
||||
use Box\Mod\Extension\Entity\Extension;
|
||||
use FOSSBilling\Tools;
|
||||
use FOSSBilling\Validation\Api\RequiredParams;
|
||||
|
||||
@@ -182,7 +183,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
|
||||
$ext = $this->_getExtension($data);
|
||||
|
||||
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminDeactivateExtension', 'params' => ['id' => $ext->id]]);
|
||||
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminDeactivateExtension', 'params' => ['id' => $ext->getId()]]);
|
||||
|
||||
$service = $this->getService();
|
||||
$service->deactivate($ext);
|
||||
@@ -283,8 +284,8 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
private function _getExtension($data)
|
||||
{
|
||||
$service = $this->getService();
|
||||
$ext = $service->findExtension($data['type'], $data['id']);
|
||||
if (!$ext instanceof \Model_Extension) {
|
||||
$ext = $service->getExtensionRepository()->findOneByTypeAndName($data['type'], $data['id']);
|
||||
if (!$ext instanceof Extension) {
|
||||
throw new \FOSSBilling\Exception('Extension not found');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Extension\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
|
||||
/**
|
||||
* Installed extension record.
|
||||
*
|
||||
* The `extension` table tracks which extensions (modules, themes, payment
|
||||
* gateways, hooks, ...) are installed in this FOSSBilling instance, along
|
||||
* with their current status and version. The actual extension code lives
|
||||
* on disk; this row is only the metadata used by the extension manager.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: \Box\Mod\Extension\Repository\ExtensionRepository::class)]
|
||||
#[ORM\Table(name: 'extension')]
|
||||
#[ORM\Index(name: 'idx_extension_type', columns: ['type'])]
|
||||
class Extension implements ApiArrayInterface
|
||||
{
|
||||
final public const string TYPE_MOD = 'mod';
|
||||
final public const string TYPE_THEME = 'theme';
|
||||
final public const string TYPE_PG = 'pg';
|
||||
final public const string TYPE_TRANSLATION = 'translation';
|
||||
final public const string TYPE_HOOK = 'hook';
|
||||
|
||||
final public const string STATUS_INSTALLED = 'installed';
|
||||
final public const string STATUS_DEACTIVATED = 'deactivated';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
|
||||
private ?string $status = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
|
||||
private ?string $version = null;
|
||||
|
||||
public function toApiArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'type' => $this->type,
|
||||
'name' => $this->name,
|
||||
'status' => $this->status,
|
||||
'version' => $this->version,
|
||||
];
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?string $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(?string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): ?string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(?string $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): ?string
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function setVersion(?string $version): self
|
||||
{
|
||||
$this->version = $version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Extension\Repository;
|
||||
|
||||
use Box\Mod\Extension\Entity\Extension;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class ExtensionRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* Build a QueryBuilder for filtering installed extensions.
|
||||
*
|
||||
* Accepted keys in `$data`:
|
||||
* - `type` (string) exact match on `type`
|
||||
* - `status` (string) exact match on `status`
|
||||
* - `search` (string) LIKE on `name`
|
||||
*/
|
||||
public function getSearchQueryBuilder(array $data = []): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('e');
|
||||
|
||||
if (!empty($data['type'])) {
|
||||
$qb->andWhere('e.type = :type')
|
||||
->setParameter('type', $data['type']);
|
||||
}
|
||||
|
||||
if (!empty($data['status'])) {
|
||||
$qb->andWhere('e.status = :status')
|
||||
->setParameter('status', $data['status']);
|
||||
}
|
||||
|
||||
if (!empty($data['search'])) {
|
||||
$qb->andWhere('e.name LIKE :search')
|
||||
->setParameter('search', '%' . $data['search'] . '%');
|
||||
}
|
||||
|
||||
$qb->orderBy('e.type', 'ASC')
|
||||
->addOrderBy('e.status', 'DESC')
|
||||
->addOrderBy('e.id', 'ASC');
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single extension by `(type, name)`.
|
||||
*/
|
||||
public function findOneByTypeAndName(string $type, string $name): ?Extension
|
||||
{
|
||||
return $this->findOneBy(['type' => $type, 'name' => $name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all extensions of a given type that are currently installed.
|
||||
*
|
||||
* @return Extension[]
|
||||
*/
|
||||
public function findInstalledByType(string $type): array
|
||||
{
|
||||
return $this->findBy(['type' => $type, 'status' => Extension::STATUS_INSTALLED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an extension with the given type and name exists and is
|
||||
* currently marked as installed.
|
||||
*/
|
||||
public function existsActiveByTypeAndName(string $type, string $name): bool
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'type' => $type,
|
||||
'name' => $name,
|
||||
'status' => Extension::STATUS_INSTALLED,
|
||||
]) !== null;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Box\Mod\Extension;
|
||||
|
||||
use Box\Mod\Extension\Entity\Extension;
|
||||
use Box\Mod\Extension\Entity\ExtensionMeta;
|
||||
use Box\Mod\Extension\Repository\ExtensionMetaRepository;
|
||||
use Box\Mod\Extension\Repository\ExtensionRepository;
|
||||
use FOSSBilling\Config;
|
||||
use FOSSBilling\InjectionAwareInterface;
|
||||
use Symfony\Component\Filesystem\Exception\IOException;
|
||||
@@ -23,6 +27,8 @@ use Symfony\Contracts\Cache\ItemInterface;
|
||||
class Service implements InjectionAwareInterface
|
||||
{
|
||||
protected ?\Pimple\Container $di = null;
|
||||
protected ExtensionRepository $extensionRepository;
|
||||
protected ExtensionMetaRepository $extensionMetaRepository;
|
||||
|
||||
public function __construct(private ?Filesystem $filesystem = null)
|
||||
{
|
||||
@@ -32,6 +38,8 @@ class Service implements InjectionAwareInterface
|
||||
public function setDi(\Pimple\Container $di): void
|
||||
{
|
||||
$this->di = $di;
|
||||
$this->extensionRepository = $di['em']->getRepository(Extension::class);
|
||||
$this->extensionMetaRepository = $di['em']->getRepository(ExtensionMeta::class);
|
||||
}
|
||||
|
||||
public function getDi(): ?\Pimple\Container
|
||||
@@ -39,6 +47,16 @@ class Service implements InjectionAwareInterface
|
||||
return $this->di;
|
||||
}
|
||||
|
||||
public function getExtensionRepository(): ExtensionRepository
|
||||
{
|
||||
return $this->extensionRepository;
|
||||
}
|
||||
|
||||
public function getExtensionMetaRepository(): ExtensionMetaRepository
|
||||
{
|
||||
return $this->extensionMetaRepository;
|
||||
}
|
||||
|
||||
public function getModulePermissions(): array
|
||||
{
|
||||
return [
|
||||
@@ -73,17 +91,7 @@ class Service implements InjectionAwareInterface
|
||||
return true;
|
||||
}
|
||||
|
||||
$query = "SELECT id
|
||||
FROM extension
|
||||
WHERE type = :type
|
||||
AND status = 'installed'
|
||||
AND name = :id
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$id_or_null = $this->di['db']->getCell($query, ['type' => $type, 'id' => $id]);
|
||||
|
||||
return (bool) $id_or_null;
|
||||
return $this->getExtensionRepository()->existsActiveByTypeAndName($type, $id);
|
||||
}
|
||||
|
||||
public static function onBeforeAdminCronRun(\Box_Event $event): bool
|
||||
@@ -102,45 +110,25 @@ class Service implements InjectionAwareInterface
|
||||
|
||||
public function removeNotExistingModules(): int
|
||||
{
|
||||
$list = $this->di['db']->find('Extension', "type = 'mod'");
|
||||
$list = $this->getExtensionRepository()->findBy(['type' => Extension::TYPE_MOD]);
|
||||
$removedItems = 0;
|
||||
foreach ($list as $ext) {
|
||||
try {
|
||||
$mod = $this->di['mod']($ext->name);
|
||||
$mod = $this->di['mod']($ext->getName());
|
||||
$mod->getManifest();
|
||||
} catch (\Exception) {
|
||||
$this->di['db']->trash($ext);
|
||||
$this->di['em']->remove($ext);
|
||||
++$removedItems;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedItems > 0) {
|
||||
$this->di['em']->flush();
|
||||
}
|
||||
|
||||
return $removedItems;
|
||||
}
|
||||
|
||||
public function getSearchQuery($filter): array
|
||||
{
|
||||
$search = $filter['search'] ?? null;
|
||||
$type = $filter['type'] ?? null;
|
||||
|
||||
$params = [];
|
||||
$sql = "SELECT * FROM extension
|
||||
WHERE status = 'installed' ";
|
||||
|
||||
if ($type !== null) {
|
||||
$sql .= ' AND type = :type';
|
||||
$params[':type'] = $type;
|
||||
}
|
||||
|
||||
if ($search !== null) {
|
||||
$sql .= ' AND name LIKE :search';
|
||||
$params[':search'] = '%' . $search . '%';
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY type ASC, status DESC, id ASC';
|
||||
|
||||
return [$sql, $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed[]
|
||||
*/
|
||||
@@ -148,8 +136,11 @@ class Service implements InjectionAwareInterface
|
||||
{
|
||||
$this->removeNotExistingModules();
|
||||
|
||||
[$sql, $params] = $this->getSearchQuery($filter);
|
||||
$installed = $this->di['db']->getAll($sql, $params);
|
||||
$installedEntities = $this->getExtensionRepository()->getSearchQueryBuilder($filter)
|
||||
->andWhere('e.status = :status')
|
||||
->setParameter('status', Extension::STATUS_INSTALLED)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$has_settings = $filter['has_settings'] ?? null;
|
||||
$only_installed = $filter['installed'] ?? null;
|
||||
@@ -168,20 +159,20 @@ class Service implements InjectionAwareInterface
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($installed as $im) {
|
||||
$m = $this->di['mod']($im['name']);
|
||||
foreach ($installedEntities as $ext) {
|
||||
$m = $this->di['mod']($ext->getName());
|
||||
|
||||
try {
|
||||
$manifest = $m->getManifest();
|
||||
} catch (\Exception $e) {
|
||||
error_log("Error while decoding the manifest file for {$im['name']} : {$e->getMessage()}.");
|
||||
error_log("Error while decoding the manifest file for {$ext->getName()} : {$e->getMessage()}.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifest['version'] = $im['version'];
|
||||
$manifest['status'] = $im['status'];
|
||||
if ($im['type'] == 'mod' && $im['status'] == 'installed') {
|
||||
$manifest['version'] = $ext->getVersion();
|
||||
$manifest['status'] = $ext->getStatus();
|
||||
if ($ext->getType() === Extension::TYPE_MOD && $ext->getStatus() === Extension::STATUS_INSTALLED) {
|
||||
$manifest['has_settings'] = $m->hasSettingsPage();
|
||||
}
|
||||
|
||||
@@ -193,9 +184,9 @@ class Service implements InjectionAwareInterface
|
||||
$a = $this->_getAvailable();
|
||||
|
||||
// unset installed modules
|
||||
foreach ($installed as $ins) {
|
||||
if (in_array($ins['name'], $a)) {
|
||||
$key = array_search($ins['name'], $a);
|
||||
foreach ($installedEntities as $ins) {
|
||||
if (in_array($ins->getName(), $a, true)) {
|
||||
$key = array_search($ins->getName(), $a, true);
|
||||
unset($a[$key]);
|
||||
}
|
||||
}
|
||||
@@ -383,15 +374,7 @@ class Service implements InjectionAwareInterface
|
||||
return $this->di['url']->adminLink(ltrim($uri, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Model_Extension|null
|
||||
*/
|
||||
public function findExtension($type, $id)
|
||||
{
|
||||
return $this->di['db']->findOne('Extension', 'type = ? and name = ? ', [$type, $id]);
|
||||
}
|
||||
|
||||
public function update(\Model_Extension $model): never
|
||||
public function update(Extension $model): never
|
||||
{
|
||||
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('extension', 'manage_extensions');
|
||||
|
||||
@@ -403,23 +386,23 @@ class Service implements InjectionAwareInterface
|
||||
*
|
||||
* @return array|array{has_settings: bool, id: string, redirect: bool, type: string}
|
||||
*/
|
||||
public function activate(\Model_Extension $ext): array
|
||||
public function activate(Extension $ext): array
|
||||
{
|
||||
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('extension', 'manage_extensions');
|
||||
|
||||
$result = [
|
||||
'id' => $ext->name,
|
||||
'type' => $ext->type,
|
||||
'id' => $ext->getName(),
|
||||
'type' => $ext->getType(),
|
||||
'redirect' => false,
|
||||
'has_settings' => false,
|
||||
];
|
||||
|
||||
switch ($ext->type) {
|
||||
switch ($ext->getType()) {
|
||||
case \FOSSBilling\ExtensionManager::TYPE_MOD:
|
||||
$mod = $this->di['mod']($ext->name);
|
||||
$mod = $this->di['mod']($ext->getName());
|
||||
$manifest = $mod->getManifest();
|
||||
$this->installModule($ext);
|
||||
$ext->version = $manifest['version'];
|
||||
$ext->setVersion($manifest['version'] !== null ? (string) $manifest['version'] : null);
|
||||
$result['redirect'] = $mod->hasAdminController();
|
||||
$result['has_settings'] = $mod->hasSettingsPage();
|
||||
|
||||
@@ -429,8 +412,8 @@ class Service implements InjectionAwareInterface
|
||||
break;
|
||||
}
|
||||
|
||||
$ext->status = 'installed';
|
||||
$this->di['db']->store($ext);
|
||||
$ext->setStatus(Extension::STATUS_INSTALLED);
|
||||
$this->di['em']->flush();
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -440,13 +423,13 @@ class Service implements InjectionAwareInterface
|
||||
*
|
||||
* @throws \FOSSBilling\InformationException
|
||||
*/
|
||||
public function deactivate(\Model_Extension $ext): bool
|
||||
public function deactivate(Extension $ext): bool
|
||||
{
|
||||
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('extension', 'manage_extensions');
|
||||
|
||||
switch ($ext->type) {
|
||||
switch ($ext->getType()) {
|
||||
case \FOSSBilling\ExtensionManager::TYPE_HOOK:
|
||||
$file = Path::changeExtension(ucfirst($ext->name), '.php');
|
||||
$file = Path::changeExtension(ucfirst((string) $ext->getName()), '.php');
|
||||
$destination = Path::join(PATH_LIBRARY, 'Hook', $file);
|
||||
if ($this->filesystem->exists($destination)) {
|
||||
$this->filesystem->remove($destination);
|
||||
@@ -455,7 +438,7 @@ class Service implements InjectionAwareInterface
|
||||
break;
|
||||
|
||||
case \FOSSBilling\ExtensionManager::TYPE_MOD:
|
||||
$mod = $ext->name;
|
||||
$mod = $ext->getName();
|
||||
if ($this->isCoreModule($mod)) {
|
||||
throw new \FOSSBilling\InformationException('Core modules are an integral part of the FOSSBilling system and cannot be deactivated.');
|
||||
}
|
||||
@@ -466,7 +449,8 @@ class Service implements InjectionAwareInterface
|
||||
break;
|
||||
}
|
||||
|
||||
$this->di['db']->trash($ext);
|
||||
$this->di['em']->remove($ext);
|
||||
$this->di['em']->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -606,11 +590,11 @@ class Service implements InjectionAwareInterface
|
||||
return $query->executeQuery()->fetchFirstColumn();
|
||||
}
|
||||
|
||||
private function installModule(\Model_Extension $ext): bool
|
||||
private function installModule(Extension $ext): bool
|
||||
{
|
||||
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('extension', 'manage_extensions');
|
||||
|
||||
$mod = $this->di['mod']($ext->name);
|
||||
$mod = $this->di['mod']($ext->getName());
|
||||
|
||||
if ($mod->isCore()) {
|
||||
throw new \FOSSBilling\InformationException('FOSSBilling core modules cannot be installed or removed');
|
||||
@@ -632,30 +616,36 @@ class Service implements InjectionAwareInterface
|
||||
}
|
||||
}
|
||||
|
||||
$ext->version = $info['version'];
|
||||
$this->di['db']->store($ext);
|
||||
$ext->setVersion($info['version'] !== null ? (string) $info['version'] : null);
|
||||
$this->di['em']->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function activateExistingExtension(array $data): array
|
||||
{
|
||||
$ext = $this->findExtension($data['type'], $data['id']);
|
||||
if (!$ext instanceof \Model_Extension) {
|
||||
$ext = $this->di['db']->dispense('Extension');
|
||||
$ext->name = $data['id'];
|
||||
$ext->type = $data['type'];
|
||||
$ext->version = null;
|
||||
$ext->status = 'deactivated';
|
||||
$ext_id = $this->di['db']->store($ext);
|
||||
$ext = $this->getExtensionRepository()->findOneByTypeAndName($data['type'], $data['id']);
|
||||
$persistedNewly = false;
|
||||
if (!$ext instanceof Extension) {
|
||||
$ext = new Extension();
|
||||
$ext->setName($data['id']);
|
||||
$ext->setType($data['type']);
|
||||
$ext->setVersion(null);
|
||||
$ext->setStatus(Extension::STATUS_DEACTIVATED);
|
||||
$this->di['em']->persist($ext);
|
||||
$this->di['em']->flush();
|
||||
$persistedNewly = true;
|
||||
}
|
||||
$ext_id ??= $ext->id;
|
||||
$ext_id = $ext->getId();
|
||||
$this->di['events_manager']->fire(['event' => 'onBeforeAdminActivateExtension', 'params' => ['id' => $ext_id]]);
|
||||
|
||||
try {
|
||||
$result = $this->activate($ext);
|
||||
} catch (\Exception $e) {
|
||||
$this->di['db']->trash($ext);
|
||||
if ($persistedNewly) {
|
||||
$this->di['em']->remove($ext);
|
||||
$this->di['em']->flush();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
@@ -670,19 +660,18 @@ class Service implements InjectionAwareInterface
|
||||
return $this->di['cache']->get("config_{$ext}", function (ItemInterface $item) use ($ext) {
|
||||
$item->expiresAfter(60 * 60);
|
||||
|
||||
$c = $this->di['db']->findOne('ExtensionMeta', 'extension = :ext AND meta_key = :key', [':ext' => $ext, ':key' => 'config']);
|
||||
if (is_null($c)) {
|
||||
$c = $this->di['db']->dispense('ExtensionMeta');
|
||||
$c->extension = $ext;
|
||||
$c->meta_key = 'config';
|
||||
$c->meta_value = null;
|
||||
$c->created_at = date('Y-m-d H:i:s');
|
||||
$c->updated_at = date('Y-m-d H:i:s');
|
||||
$this->di['db']->store($c);
|
||||
$meta = $this->getExtensionMetaRepository()->findOneByExtensionAndScope($ext, 'config');
|
||||
if ($meta === null) {
|
||||
$meta = new ExtensionMeta();
|
||||
$meta->setExtension($ext);
|
||||
$meta->setMetaKey('config');
|
||||
$meta->setMetaValue(null);
|
||||
$this->di['em']->persist($meta);
|
||||
$this->di['em']->flush();
|
||||
$config = [];
|
||||
} else {
|
||||
$config = $this->di['crypt']->decrypt($c->meta_value, $this->_getSalt());
|
||||
$config = is_string($config) ? json_decode($config, true) : [];
|
||||
$decrypted = $this->di['crypt']->decrypt($meta->getMetaValue(), $this->_getSalt());
|
||||
$config = is_string($decrypted) ? json_decode($decrypted, true) : [];
|
||||
}
|
||||
|
||||
$config['ext'] = $ext;
|
||||
@@ -698,22 +687,22 @@ class Service implements InjectionAwareInterface
|
||||
$this->getConfig($ext); // Creates new config if it does not exist in DB
|
||||
|
||||
$this->di['events_manager']->fire(['event' => 'onBeforeAdminExtensionConfigSave', 'params' => $data]);
|
||||
$sql = "
|
||||
UPDATE extension_meta
|
||||
SET meta_value = :config
|
||||
WHERE extension = :ext
|
||||
AND meta_key = 'config'
|
||||
LIMIT 1;
|
||||
";
|
||||
|
||||
$meta = $this->getExtensionMetaRepository()->findOneByExtensionAndScope($ext, 'config');
|
||||
$config = json_encode($data);
|
||||
$config = $this->di['crypt']->encrypt($config, $this->_getSalt());
|
||||
$encrypted = $this->di['crypt']->encrypt($config, $this->_getSalt());
|
||||
|
||||
if ($meta === null) {
|
||||
$meta = new ExtensionMeta();
|
||||
$meta->setExtension($ext);
|
||||
$meta->setMetaKey('config');
|
||||
$meta->setMetaValue($encrypted);
|
||||
$this->di['em']->persist($meta);
|
||||
} else {
|
||||
$meta->setMetaValue($encrypted);
|
||||
}
|
||||
$this->di['em']->flush();
|
||||
|
||||
$params = [
|
||||
'ext' => $ext,
|
||||
'config' => $config,
|
||||
];
|
||||
$this->di['db']->exec($sql, $params);
|
||||
$this->di['events_manager']->fire(['event' => 'onAfterAdminExtensionConfigSave', 'params' => $data]);
|
||||
$this->di['logger']->info("Updated extension {$ext} configuration.");
|
||||
$this->di['cache']->delete("config_{$ext}");
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Box\Mod\Extension\Entity\ExtensionMeta;
|
||||
use Box\Mod\Extension\Repository\ExtensionMetaRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
function extensionMetaRepoCreateRepository(): ExtensionMetaRepository
|
||||
{
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
$classMeta = Mockery::mock(Doctrine\ORM\Mapping\ClassMetadata::class);
|
||||
$classMeta->name = ExtensionMeta::class;
|
||||
$classMeta->shouldReceive('getName')->andReturn(ExtensionMeta::class);
|
||||
$em->shouldReceive('getClassMetadata')->with(ExtensionMeta::class)->andReturn($classMeta);
|
||||
|
||||
$qb = new QueryBuilder($em);
|
||||
$em->shouldReceive('createQueryBuilder')->andReturn($qb);
|
||||
|
||||
return new ExtensionMetaRepository($em, $classMeta);
|
||||
}
|
||||
|
||||
test('createQueryBuilderForExtension sets the extension parameter', function (): void {
|
||||
$repo = extensionMetaRepoCreateRepository();
|
||||
$qb = $repo->createQueryBuilderForExtension('mod_email');
|
||||
|
||||
expect($qb)->toBeInstanceOf(QueryBuilder::class);
|
||||
expect($qb->getParameter('extension')->getValue())->toBe('mod_email');
|
||||
});
|
||||
|
||||
test('findOneByExtensionAndId delegates to findOneBy', function (): void {
|
||||
$repo = Mockery::mock(ExtensionMetaRepository::class)->makePartial();
|
||||
$repo->shouldReceive('findOneBy')
|
||||
->once()
|
||||
->with(['extension' => 'mod_email', 'id' => 5])
|
||||
->andReturn(null);
|
||||
|
||||
expect($repo->findOneByExtensionAndId('mod_email', 5))->toBeNull();
|
||||
});
|
||||
|
||||
test('findOneByExtensionAndScope returns the first match', function (): void {
|
||||
$meta = new ExtensionMeta();
|
||||
|
||||
$repo = Mockery::mock(ExtensionMetaRepository::class)->makePartial();
|
||||
$repo->shouldReceive('findByExtensionAndScope')
|
||||
->once()
|
||||
->with('mod_email', 'config', null, null, ['id' => 'ASC'], 1)
|
||||
->andReturn([$meta]);
|
||||
|
||||
expect($repo->findOneByExtensionAndScope('mod_email', 'config'))->toBe($meta);
|
||||
});
|
||||
|
||||
test('findOneByExtensionAndScope returns null on empty result', function (): void {
|
||||
$repo = Mockery::mock(ExtensionMetaRepository::class)->makePartial();
|
||||
$repo->shouldReceive('findByExtensionAndScope')
|
||||
->once()
|
||||
->andReturn([]);
|
||||
|
||||
expect($repo->findOneByExtensionAndScope('mod_email', 'config'))->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Box\Mod\Extension\Entity\Extension;
|
||||
use Box\Mod\Extension\Repository\ExtensionRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
function extensionRepoCreateRepository(): ExtensionRepository
|
||||
{
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
$classMeta = Mockery::mock(Doctrine\ORM\Mapping\ClassMetadata::class);
|
||||
$classMeta->name = Extension::class;
|
||||
$classMeta->shouldReceive('getName')->andReturn(Extension::class);
|
||||
$em->shouldReceive('getClassMetadata')->with(Extension::class)->andReturn($classMeta);
|
||||
|
||||
$qb = new QueryBuilder($em);
|
||||
$em->shouldReceive('createQueryBuilder')->andReturn($qb);
|
||||
|
||||
return new ExtensionRepository($em, $classMeta);
|
||||
}
|
||||
|
||||
test('getSearchQueryBuilder applies status, type and search filters', function (): void {
|
||||
$repo = extensionRepoCreateRepository();
|
||||
$qb = $repo->getSearchQueryBuilder([
|
||||
'type' => 'mod',
|
||||
'status' => 'installed',
|
||||
'search' => 'sample',
|
||||
]);
|
||||
|
||||
expect($qb)->toBeInstanceOf(QueryBuilder::class);
|
||||
|
||||
$dqlParts = $qb->getDQLParts();
|
||||
expect($dqlParts['where']->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('getSearchQueryBuilder orders by type, status, id', function (): void {
|
||||
$repo = extensionRepoCreateRepository();
|
||||
$qb = $repo->getSearchQueryBuilder([]);
|
||||
|
||||
$orderBy = $qb->getDQLPart('orderBy');
|
||||
expect(count($orderBy))->toBe(3);
|
||||
});
|
||||
|
||||
test('findOneByTypeAndName delegates to findOneBy', function (): void {
|
||||
$repo = Mockery::mock(ExtensionRepository::class)->makePartial();
|
||||
$repo->shouldReceive('findOneBy')
|
||||
->once()
|
||||
->with(['type' => 'mod', 'name' => 'sample'])
|
||||
->andReturn(null);
|
||||
|
||||
expect($repo->findOneByTypeAndName('mod', 'sample'))->toBeNull();
|
||||
});
|
||||
|
||||
test('findInstalledByType returns installed extensions', function (): void {
|
||||
$repo = Mockery::mock(ExtensionRepository::class)->makePartial();
|
||||
$repo->shouldReceive('findBy')
|
||||
->once()
|
||||
->with(['type' => 'mod', 'status' => Extension::STATUS_INSTALLED])
|
||||
->andReturn([]);
|
||||
|
||||
expect($repo->findInstalledByType('mod'))->toBe([]);
|
||||
});
|
||||
|
||||
test('existsActiveByTypeAndName returns true when found', function (): void {
|
||||
$repo = Mockery::mock(ExtensionRepository::class)->makePartial();
|
||||
$entity = new Extension();
|
||||
$repo->shouldReceive('findOneBy')
|
||||
->once()
|
||||
->with(['type' => 'mod', 'name' => 'sample', 'status' => Extension::STATUS_INSTALLED])
|
||||
->andReturn($entity);
|
||||
|
||||
expect($repo->existsActiveByTypeAndName('mod', 'sample'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('existsActiveByTypeAndName returns false when not found', function (): void {
|
||||
$repo = Mockery::mock(ExtensionRepository::class)->makePartial();
|
||||
$repo->shouldReceive('findOneBy')
|
||||
->once()
|
||||
->andReturn(null);
|
||||
|
||||
expect($repo->existsActiveByTypeAndName('mod', 'sample'))->toBeFalse();
|
||||
});
|
||||
@@ -5,12 +5,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Box\Mod\Extension\Entity\Extension;
|
||||
use Box\Mod\Extension\Entity\ExtensionMeta;
|
||||
use Box\Mod\Extension\Repository\ExtensionMetaRepository;
|
||||
use Box\Mod\Extension\Repository\ExtensionRepository;
|
||||
use Box\Mod\Extension\Service;
|
||||
|
||||
use function Tests\Helpers\container;
|
||||
use function Tests\Helpers\setEntityId;
|
||||
|
||||
class ExtensionPdoMock extends PDO
|
||||
{
|
||||
@@ -26,8 +33,53 @@ class ExtensionPdoStatementsMock extends PDOStatement
|
||||
}
|
||||
}
|
||||
|
||||
function extensionCreateEntity(int $id, string $type = 'mod', string $name = 'extensionName', string $status = 'installed', ?string $version = '1'): Extension
|
||||
{
|
||||
$entity = new Extension();
|
||||
setEntityId($entity, $id);
|
||||
$entity->setType($type);
|
||||
$entity->setName($name);
|
||||
$entity->setStatus($status);
|
||||
$entity->setVersion($version);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
function extensionCreateExtensionMetaEntity(int $id, string $extensionName = 'extensionName', string $metaKey = 'config', ?string $metaValue = null): ExtensionMeta
|
||||
{
|
||||
$entity = new ExtensionMeta();
|
||||
setEntityId($entity, $id);
|
||||
$entity->setExtension($extensionName);
|
||||
$entity->setMetaKey($metaKey);
|
||||
$entity->setMetaValue($metaValue);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a partial Mockery EntityManager that returns the right repository mock
|
||||
* for each entity class requested.
|
||||
*/
|
||||
function extensionBuildEm(?ExtensionRepository $extensionRepository = null, ?ExtensionMetaRepository $metaRepository = null, bool $ignoreMissing = true): Doctrine\ORM\EntityManagerInterface&Mockery\MockInterface
|
||||
{
|
||||
$extensionRepository ??= Mockery::mock(ExtensionRepository::class)->shouldIgnoreMissing();
|
||||
$metaRepository ??= Mockery::mock(ExtensionMetaRepository::class)->shouldIgnoreMissing();
|
||||
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
if ($ignoreMissing) {
|
||||
$em->shouldIgnoreMissing();
|
||||
}
|
||||
$em->shouldReceive('getRepository')->andReturnUsing(static fn (string $class): object => match ($class) {
|
||||
Extension::class => $extensionRepository,
|
||||
ExtensionMeta::class => $metaRepository,
|
||||
default => $metaRepository,
|
||||
});
|
||||
|
||||
return $em;
|
||||
}
|
||||
|
||||
test('getDi returns the dependency injection container', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
$di = container();
|
||||
$service->setDi($di);
|
||||
$getDi = $service->getDi();
|
||||
@@ -35,7 +87,7 @@ test('getDi returns the dependency injection container', function (): void {
|
||||
});
|
||||
|
||||
test('isCoreModule checks if module is core module', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
$coreModules = ['extension', 'cron', 'staff'];
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
$modMock->shouldReceive('getCoreModules')
|
||||
@@ -58,7 +110,7 @@ test('isCoreModule checks if module is core module', function (): void {
|
||||
});
|
||||
|
||||
test('isExtensionActive returns false when module not found', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
$coreModules = ['extension', 'cron', 'staff'];
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
$modMock->shouldReceive('getCoreModules')
|
||||
@@ -66,14 +118,16 @@ test('isExtensionActive returns false when module not found', function (): void
|
||||
->once()
|
||||
->andReturn($coreModules);
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('getCell')
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('existsActiveByTypeAndName')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(null);
|
||||
->andReturn(false);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
|
||||
$service->setDi($di);
|
||||
@@ -84,25 +138,25 @@ test('isExtensionActive returns false when module not found', function (): void
|
||||
});
|
||||
|
||||
test('removeNotExistingModules removes non-existing modules', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$model = new Model_Extension();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model->name = 'extensionName';
|
||||
$service = new Service();
|
||||
$ext = extensionCreateEntity(1, 'mod', 'extensionName');
|
||||
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
$modMock->shouldReceive('getManifest')->andThrow(new Exception());
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('find')
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('findBy')
|
||||
->with(['type' => Extension::TYPE_MOD])
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn([$model]);
|
||||
$dbMock->shouldReceive('trash')
|
||||
->atLeast()
|
||||
->once();
|
||||
->andReturn([$ext]);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
$em->shouldReceive('remove')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
|
||||
$service->setDi($di);
|
||||
@@ -112,54 +166,56 @@ test('removeNotExistingModules removes non-existing modules', function (): void
|
||||
expect($result > 0)->toBeTrue();
|
||||
});
|
||||
|
||||
// Data provider for search query tests
|
||||
$searchQueryData = [
|
||||
[[], 'SELECT * FROM extension', []],
|
||||
[['type' => 'mod'], 'AND type = :type', [':type' => 'mod']],
|
||||
[['search' => 'FindUp'], 'AND name LIKE :search', [':search' => 'FindUp']],
|
||||
];
|
||||
test('getSearchQueryBuilder returns a QueryBuilder', function (): void {
|
||||
$service = new Service();
|
||||
|
||||
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
|
||||
$qb->shouldReceive('andWhere')->andReturnSelf();
|
||||
$qb->shouldReceive('setParameter')->andReturnSelf();
|
||||
$qb->shouldReceive('orderBy')->andReturnSelf();
|
||||
$qb->shouldReceive('addOrderBy')->andReturnSelf();
|
||||
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('getSearchQueryBuilder')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn($qb);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
|
||||
test('getSearchQuery returns correct SQL and params', function (array $data, string $expectedStr, array $expectedParams): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$di = container();
|
||||
$di['em'] = $em;
|
||||
|
||||
$service->setDi($di);
|
||||
[$sql, $params] = $service->getSearchQuery($data);
|
||||
|
||||
expect($sql)->toBeString();
|
||||
expect($params)->toBeArray();
|
||||
|
||||
expect(str_contains((string) $sql, $expectedStr))->toBeTrue();
|
||||
expect([])->toBe(array_diff_key($params, $expectedParams));
|
||||
})->with($searchQueryData);
|
||||
$result = $service->getExtensionRepository()->getSearchQueryBuilder([]);
|
||||
expect($result)->toBeInstanceOf(Doctrine\ORM\QueryBuilder::class);
|
||||
});
|
||||
|
||||
test('getExtensionsList returns filtered extensions list', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
$data = [
|
||||
'has_settings' => true,
|
||||
'active' => true,
|
||||
];
|
||||
|
||||
$model['manifest'] = '{"J":5,"0":"N"}';
|
||||
$model['type'] = 'mod';
|
||||
$model['status'] = 'installed';
|
||||
$model['name'] = 'extensionName';
|
||||
$model['version'] = '1';
|
||||
$ext = extensionCreateEntity(1, 'mod', 'extensionName', 'installed', '1');
|
||||
|
||||
$modelFind = new Model_Extension();
|
||||
$modelFind->loadBean(new Tests\Helpers\DummyBean());
|
||||
$modelFind->name = 'extensionName';
|
||||
$query = Mockery::mock(Doctrine\ORM\Query::class);
|
||||
$query->shouldReceive('getResult')->andReturn([$ext]);
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('getAll')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn([$model]);
|
||||
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
|
||||
$qb->shouldReceive('andWhere')->andReturnSelf();
|
||||
$qb->shouldReceive('setParameter')->andReturnSelf();
|
||||
$qb->shouldReceive('getQuery')->andReturn($query);
|
||||
|
||||
$dbMock->shouldReceive('find')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn([$modelFind]);
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
|
||||
$extensionRepository->shouldReceive('findBy')->andReturn([]);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
$em->shouldReceive('remove')->andReturnNull();
|
||||
$em->shouldReceive('flush')->andReturnNull();
|
||||
|
||||
$coreModules = ['extension', 'cron', 'staff'];
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
@@ -177,7 +233,7 @@ test('getExtensionsList returns filtered extensions list', function (): void {
|
||||
->andReturn(true);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
|
||||
$service->setDi($di);
|
||||
@@ -187,31 +243,28 @@ test('getExtensionsList returns filtered extensions list', function (): void {
|
||||
});
|
||||
|
||||
test('getExtensionsList returns only installed extensions', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
$data = [
|
||||
'installed' => true,
|
||||
];
|
||||
|
||||
$model['manifest'] = '{"J":5,"0":"N"}';
|
||||
$model['type'] = 'mod';
|
||||
$model['status'] = 'installed';
|
||||
$model['name'] = 'extensionName';
|
||||
$model['version'] = '1';
|
||||
$ext = extensionCreateEntity(1, 'mod', 'extensionName', 'installed', '1');
|
||||
|
||||
$modelFind = new Model_Extension();
|
||||
$modelFind->loadBean(new Tests\Helpers\DummyBean());
|
||||
$modelFind->name = 'extensionName';
|
||||
$query = Mockery::mock(Doctrine\ORM\Query::class);
|
||||
$query->shouldReceive('getResult')->andReturn([$ext]);
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('getAll')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn([$model]);
|
||||
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
|
||||
$qb->shouldReceive('andWhere')->andReturnSelf();
|
||||
$qb->shouldReceive('setParameter')->andReturnSelf();
|
||||
$qb->shouldReceive('getQuery')->andReturn($query);
|
||||
|
||||
$dbMock->shouldReceive('find')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn([$modelFind]);
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
|
||||
$extensionRepository->shouldReceive('findBy')->andReturn([]);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
$em->shouldReceive('remove')->andReturnNull();
|
||||
$em->shouldReceive('flush')->andReturnNull();
|
||||
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
$modMock->shouldReceive('getManifest')
|
||||
@@ -224,7 +277,7 @@ test('getExtensionsList returns only installed extensions', function (): void {
|
||||
->andReturn(true);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
|
||||
$service->setDi($di);
|
||||
@@ -234,8 +287,8 @@ test('getExtensionsList returns only installed extensions', function (): void {
|
||||
});
|
||||
|
||||
test('getAdminNavigation returns admin navigation', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$extensionServiceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
|
||||
$service = new Service();
|
||||
$extensionServiceMock = Mockery::mock(Service::class)->makePartial();
|
||||
$extensionServiceMock->shouldAllowMockingProtectedMethods();
|
||||
$extensionServiceMock->shouldReceive('getConfig')
|
||||
->atLeast()
|
||||
@@ -322,28 +375,28 @@ test('getAdminNavigation returns admin navigation', function (): void {
|
||||
});
|
||||
|
||||
test('findExtension finds an extension', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('findOne')
|
||||
$service = new Service();
|
||||
$ext = extensionCreateEntity(1);
|
||||
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('findOneByTypeAndName')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(new Model_Extension());
|
||||
->andReturn($ext);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
|
||||
$service->setDi($di);
|
||||
$result = $service->findExtension('mod', 'id');
|
||||
expect($result)->toBeInstanceOf('\Model_Extension');
|
||||
$result = $service->getExtensionRepository()->findOneByTypeAndName('mod', 'id');
|
||||
expect($result)->toBeInstanceOf(Extension::class);
|
||||
});
|
||||
|
||||
test('update throws exception for extensions that need manual update', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$model = new Model_Extension();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model->type = 'mod';
|
||||
$model->name = 'testExtension';
|
||||
$model->version = '2';
|
||||
$service = new Service();
|
||||
$ext = extensionCreateEntity(1, 'mod', 'testExtension', 'installed', '2');
|
||||
|
||||
$extensionStub = Mockery::mock(FOSSBilling\ExtensionManager::class);
|
||||
|
||||
@@ -356,23 +409,13 @@ test('update throws exception for extensions that need manual update', function
|
||||
|
||||
$service->setDi($di);
|
||||
|
||||
expect(fn () => $service->update($model))
|
||||
expect(fn () => $service->update($ext))
|
||||
->toThrow(FOSSBilling\Exception::class, 'Visit the extension directory for more information on updating this extension.');
|
||||
});
|
||||
|
||||
test('activate activates an extension', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$ext = new Model_Extension();
|
||||
$ext->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ext->type = 'mod';
|
||||
$ext->name = 'testExtension';
|
||||
|
||||
$expectedResult = [
|
||||
'id' => $ext->name,
|
||||
'type' => $ext->type,
|
||||
'redirect' => true,
|
||||
'has_settings' => true,
|
||||
];
|
||||
$service = new Service();
|
||||
$ext = extensionCreateEntity(1, 'mod', 'testExtension', 'deactivated');
|
||||
|
||||
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
|
||||
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
|
||||
@@ -382,52 +425,42 @@ test('activate activates an extension', function (): void {
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(['version' => 1]);
|
||||
|
||||
$modMock->shouldReceive('hasAdminController')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(true);
|
||||
|
||||
$modMock->shouldReceive('hasSettingsPage')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(true);
|
||||
|
||||
$modMock->shouldReceive('isCore')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(false);
|
||||
|
||||
$modMock->shouldReceive('install')
|
||||
->atLeast()
|
||||
->once();
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('store')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(1);
|
||||
$em = extensionBuildEm();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
|
||||
|
||||
$service->setDi($di);
|
||||
$result = $service->activate($ext);
|
||||
expect($result)->toBeArray();
|
||||
expect($result)->toBe($expectedResult);
|
||||
expect($result['id'])->toBe('testExtension');
|
||||
expect($result['type'])->toBe('mod');
|
||||
expect($result['redirect'])->toBeTrue();
|
||||
expect($result['has_settings'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('deactivate deactivates an extension', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$ext = new Model_Extension();
|
||||
$ext->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ext->type = 'mod';
|
||||
$ext->name = 'extensionTest';
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('trash')->atLeast()->once();
|
||||
$service = new Service();
|
||||
$ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed');
|
||||
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
$modMock->shouldReceive('getCoreModules')
|
||||
@@ -438,10 +471,13 @@ test('deactivate deactivates an extension', function (): void {
|
||||
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
|
||||
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
$em = extensionBuildEm();
|
||||
$em->shouldReceive('remove')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
|
||||
|
||||
$service->setDi($di);
|
||||
@@ -451,24 +487,23 @@ test('deactivate deactivates an extension', function (): void {
|
||||
});
|
||||
|
||||
test('deactivate throws exception for core modules', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$ext = new Model_Extension();
|
||||
$ext->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ext->type = 'mod';
|
||||
$ext->name = 'extensionTest';
|
||||
$service = new Service();
|
||||
$ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed');
|
||||
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
$modMock->shouldReceive('getCoreModules')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn([$ext->name]);
|
||||
->andReturn([$ext->getName()]);
|
||||
|
||||
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
|
||||
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
$em = extensionBuildEm();
|
||||
|
||||
$di = container();
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
|
||||
|
||||
$service->setDi($di);
|
||||
@@ -478,14 +513,7 @@ test('deactivate throws exception for core modules', function (): void {
|
||||
});
|
||||
|
||||
test('deactivate deactivates hook extension', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$ext = new Model_Extension();
|
||||
$ext->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ext->type = 'hook';
|
||||
$ext->name = 'extensionTest';
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('trash')->atLeast()->once();
|
||||
$ext = extensionCreateEntity(1, 'hook', 'extensionTest', 'installed');
|
||||
|
||||
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
|
||||
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
|
||||
@@ -494,10 +522,14 @@ test('deactivate deactivates hook extension', function (): void {
|
||||
$filesystemMock->shouldReceive('exists')->atLeast()->once()->andReturn(true);
|
||||
$filesystemMock->shouldReceive('remove')->atLeast()->once();
|
||||
|
||||
$service = new Box\Mod\Extension\Service($filesystemMock);
|
||||
$service = new Service($filesystemMock);
|
||||
|
||||
$em = extensionBuildEm();
|
||||
$em->shouldReceive('remove')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
|
||||
|
||||
$service->setDi($di);
|
||||
@@ -506,11 +538,7 @@ test('deactivate deactivates hook extension', function (): void {
|
||||
});
|
||||
|
||||
test('deactivate deactivates module', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$ext = new Model_Extension();
|
||||
$ext->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ext->type = 'mod';
|
||||
$ext->name = 'extensionTest';
|
||||
$ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed');
|
||||
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
$modMock->shouldReceive('getCoreModules')
|
||||
@@ -521,15 +549,16 @@ test('deactivate deactivates module', function (): void {
|
||||
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
|
||||
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('trash')->atLeast()->once();
|
||||
$em = extensionBuildEm();
|
||||
$em->shouldReceive('remove')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
|
||||
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
|
||||
|
||||
$service = new Service();
|
||||
$service->setDi($di);
|
||||
|
||||
$result = $service->deactivate($ext);
|
||||
@@ -537,10 +566,6 @@ test('deactivate deactivates module', function (): void {
|
||||
});
|
||||
|
||||
test('uninstall uninstalls an extension', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock->shouldReceive('getCell')->andReturn(null);
|
||||
|
||||
$modMock = Mockery::mock(FOSSBilling\Module::class);
|
||||
$modMock->shouldReceive('getCoreModules')
|
||||
->atLeast()
|
||||
@@ -569,14 +594,18 @@ test('uninstall uninstalls an extension', function (): void {
|
||||
$tmpDir = sys_get_temp_dir() . '/fb_test_ext_' . uniqid();
|
||||
mkdir($tmpDir, 0o755, true);
|
||||
|
||||
// Create the service with filesystem in constructor, then make it a partial mock
|
||||
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class . '[getExtensionPath]', [$filesystemMock]);
|
||||
$serviceMock = Mockery::mock(Service::class . '[getExtensionPath]', [$filesystemMock]);
|
||||
$serviceMock->shouldReceive('getExtensionPath')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn($tmpDir);
|
||||
|
||||
$di['db'] = $dbMock;
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('existsActiveByTypeAndName')->andReturn(false);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
|
||||
$di['em'] = $em;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
|
||||
|
||||
@@ -602,7 +631,7 @@ test('uninstall uninstalls an extension', function (): void {
|
||||
});
|
||||
|
||||
test('getExtensionPath rejects unsafe extension IDs', function (string $type, string $id): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
|
||||
expect(fn (): string => $service->getExtensionPath($type, $id))
|
||||
->toThrow(FOSSBilling\InformationException::class, 'Extension ID contains invalid characters.');
|
||||
@@ -614,7 +643,7 @@ test('getExtensionPath rejects unsafe extension IDs', function (string $type, st
|
||||
]);
|
||||
|
||||
test('downloadAndExtract throws exception when download URL is missing', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
$extensionMock = Mockery::mock(FOSSBilling\ExtensionManager::class);
|
||||
|
||||
$extensionMock->shouldReceive('getLatestExtensionRelease')
|
||||
@@ -636,7 +665,7 @@ test('downloadAndExtract throws exception when download URL is missing', functio
|
||||
});
|
||||
|
||||
test('getInstalledMods returns installed modules', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
$dbalMock = new class {
|
||||
public function createQueryBuilder(): object
|
||||
{
|
||||
@@ -681,6 +710,7 @@ test('getInstalledMods returns installed modules', function (): void {
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['dbal'] = $dbalMock;
|
||||
$di['em'] = extensionBuildEm();
|
||||
|
||||
$service->setDi($di);
|
||||
$result = $service->getInstalledMods();
|
||||
@@ -688,46 +718,33 @@ test('getInstalledMods returns installed modules', function (): void {
|
||||
});
|
||||
|
||||
test('activateExistingExtension activates existing extension', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$data = [
|
||||
'id' => 'extensionId',
|
||||
'type' => 'extensionType',
|
||||
];
|
||||
|
||||
$model = new Model_Extension();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
|
||||
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||
$serviceMock->shouldAllowMockingProtectedMethods();
|
||||
$serviceMock->shouldReceive('findExtension')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturnUsing(function () use ($model) {
|
||||
static $callCount = 0;
|
||||
++$callCount;
|
||||
|
||||
return $callCount === 1 ? null : $model;
|
||||
});
|
||||
$serviceMock->shouldReceive('activate')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn([]);
|
||||
|
||||
$dbMock = Mockery::mock(Box_Database::class);
|
||||
$dbMock->shouldReceive('dispense')
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('findOneByTypeAndName')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn($model);
|
||||
$dbMock->shouldReceive('store')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(1);
|
||||
->andReturn(null);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
$em->shouldReceive('persist')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$eventMock = Mockery::mock(Box_EventManager::class);
|
||||
$eventMock->shouldReceive('fire')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['events_manager'] = $eventMock;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
@@ -738,30 +755,33 @@ test('activateExistingExtension activates existing extension', function (): void
|
||||
});
|
||||
|
||||
test('activateExistingExtension throws exception on activation failure', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$data = [
|
||||
'id' => 'extensionId',
|
||||
'type' => 'extensionType',
|
||||
];
|
||||
|
||||
$model = new Model_Extension();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$model = extensionCreateEntity(1);
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
|
||||
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||
$serviceMock->shouldAllowMockingProtectedMethods();
|
||||
$serviceMock->shouldReceive('findExtension')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn($model);
|
||||
$serviceMock->shouldReceive('activate')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andThrow(new Exception());
|
||||
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('findOneByTypeAndName')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn($model);
|
||||
|
||||
$eventMock = Mockery::mock(Box_EventManager::class);
|
||||
$eventMock->shouldReceive('fire')->atLeast()->once();
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
|
||||
$di = container();
|
||||
$di['em'] = $em;
|
||||
$di['events_manager'] = $eventMock;
|
||||
|
||||
$serviceMock->setDi($di);
|
||||
@@ -771,25 +791,26 @@ test('activateExistingExtension throws exception on activation failure', functio
|
||||
});
|
||||
|
||||
test('getConfig returns extension config', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
$data = [
|
||||
'ext' => 'extensionName',
|
||||
];
|
||||
|
||||
$model = new Model_ExtensionMeta();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$meta = extensionCreateExtensionMetaEntity(1, 'extensionName', 'config', 'encryptedConfig');
|
||||
|
||||
$dbMock = Mockery::mock(Box_Database::class);
|
||||
$dbMock->shouldReceive('findOne')
|
||||
$metaRepo = Mockery::mock(ExtensionMetaRepository::class);
|
||||
$metaRepo->shouldReceive('findOneByExtensionAndScope')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn($model);
|
||||
->andReturn($meta);
|
||||
|
||||
$cryptMock = Mockery::mock(Box_Crypt::class);
|
||||
$cryptMock->shouldReceive('decrypt')->atLeast()->once();
|
||||
|
||||
$em = extensionBuildEm(null, $metaRepo);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['crypt'] = $cryptMock;
|
||||
$di['cache'] = new Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||
|
||||
@@ -800,30 +821,23 @@ test('getConfig returns extension config', function (): void {
|
||||
});
|
||||
|
||||
test('getConfig creates new ExtensionMeta when not found', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$service = new Service();
|
||||
$data = [
|
||||
'ext' => 'extensionName',
|
||||
];
|
||||
|
||||
$model = new Model_ExtensionMeta();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
|
||||
$dbMock = Mockery::mock(Box_Database::class);
|
||||
$dbMock->shouldReceive('findOne')
|
||||
$metaRepo = Mockery::mock(ExtensionMetaRepository::class);
|
||||
$metaRepo->shouldReceive('findOneByExtensionAndScope')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(null);
|
||||
$dbMock->shouldReceive('dispense')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn($model);
|
||||
$dbMock->shouldReceive('store')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(1);
|
||||
|
||||
$em = extensionBuildEm(null, $metaRepo);
|
||||
$em->shouldReceive('persist')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['cache'] = new Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||
|
||||
$service->setDi($di);
|
||||
@@ -834,12 +848,11 @@ test('getConfig creates new ExtensionMeta when not found', function (): void {
|
||||
});
|
||||
|
||||
test('setConfig sets extension config', function (): void {
|
||||
$service = new Box\Mod\Extension\Service();
|
||||
$data = [
|
||||
'ext' => 'extensionName',
|
||||
];
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
|
||||
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||
$serviceMock->shouldAllowMockingProtectedMethods();
|
||||
$serviceMock->shouldReceive('hasManagePermission')
|
||||
->with('extensionName')
|
||||
@@ -859,19 +872,21 @@ test('setConfig sets extension config', function (): void {
|
||||
->once()
|
||||
->andReturn('encryptedConfig');
|
||||
|
||||
$model = new Model_ExtensionMeta();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$dbMock = Mockery::mock(Box_Database::class);
|
||||
$dbMock->shouldReceive('exec')
|
||||
$metaRepo = Mockery::mock(ExtensionMetaRepository::class);
|
||||
$metaRepo->shouldReceive('findOneByExtensionAndScope')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn([]);
|
||||
->andReturn(null);
|
||||
|
||||
$em = extensionBuildEm(null, $metaRepo);
|
||||
$em->shouldReceive('persist')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$eventMock = Mockery::mock(Box_EventManager::class);
|
||||
$eventMock->shouldReceive('fire')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['tools'] = $toolsMock;
|
||||
$di['crypt'] = $cryptMock;
|
||||
$di['events_manager'] = $eventMock;
|
||||
@@ -885,7 +900,7 @@ test('setConfig sets extension config', function (): void {
|
||||
});
|
||||
|
||||
test('hasManagePermission denies access when module does not declare manage_settings', function (): void {
|
||||
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
|
||||
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||
$serviceMock->shouldAllowMockingProtectedMethods();
|
||||
|
||||
$staffMock = Mockery::mock(Box\Mod\Staff\Service::class);
|
||||
@@ -901,14 +916,16 @@ test('hasManagePermission denies access when module does not declare manage_sett
|
||||
->once()
|
||||
->andReturn([]);
|
||||
|
||||
$dbMock = Mockery::mock(Box_Database::class);
|
||||
$dbMock->shouldReceive('getCell')
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('existsActiveByTypeAndName')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(1);
|
||||
->andReturn(true);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn (): Mockery\MockInterface => $modMock);
|
||||
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffMock);
|
||||
|
||||
@@ -921,11 +938,11 @@ test('hasManagePermission denies access when module does not declare manage_sett
|
||||
]);
|
||||
|
||||
expect(fn () => $serviceMock->hasManagePermission('mod_support'))
|
||||
->toThrow(new FOSSBilling\InformationException('You do not have permission to perform this action', [], 403));
|
||||
->toThrow(new FOSSBilling\InformationException('You do not have permission to perform this action', [], 403));
|
||||
});
|
||||
|
||||
test('hasManagePermission allows access when module declares manage_settings and user has it', function (): void {
|
||||
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
|
||||
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||
$serviceMock->shouldAllowMockingProtectedMethods();
|
||||
|
||||
$staffMock = Mockery::mock(Box\Mod\Staff\Service::class);
|
||||
@@ -946,14 +963,16 @@ test('hasManagePermission allows access when module declares manage_settings and
|
||||
->once()
|
||||
->andReturn([]);
|
||||
|
||||
$dbMock = Mockery::mock(Box_Database::class);
|
||||
$dbMock->shouldReceive('getCell')
|
||||
$extensionRepository = Mockery::mock(ExtensionRepository::class);
|
||||
$extensionRepository->shouldReceive('existsActiveByTypeAndName')
|
||||
->atLeast()
|
||||
->once()
|
||||
->andReturn(1);
|
||||
->andReturn(true);
|
||||
|
||||
$em = extensionBuildEm($extensionRepository);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
$di['mod'] = $di->protect(fn (): Mockery\MockInterface => $modMock);
|
||||
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffMock);
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ namespace Box\Mod\Massmailer;
|
||||
use Box\Mod\Massmailer\Entity\MassmailerMessage;
|
||||
use Box\Mod\Massmailer\Repository\MassmailerMessageRepository;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use FOSSBilling\Enums\ClientOrderStatusEnum;
|
||||
use FOSSBilling\Enums\ClientStatusEnum;
|
||||
use FOSSBilling\Environment;
|
||||
use FOSSBilling\InformationException;
|
||||
|
||||
@@ -31,17 +33,17 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
self::FILTER_HAS_ORDER_WITH_STATUS,
|
||||
];
|
||||
private const array CLIENT_STATUSES = [
|
||||
\Model_Client::ACTIVE,
|
||||
\Model_Client::SUSPENDED,
|
||||
\Model_Client::CANCELED,
|
||||
ClientStatusEnum::ACTIVE->value,
|
||||
ClientStatusEnum::SUSPENDED->value,
|
||||
ClientStatusEnum::CANCELED->value,
|
||||
];
|
||||
private const array ORDER_STATUSES = [
|
||||
\Model_ClientOrder::STATUS_PENDING_SETUP,
|
||||
\Model_ClientOrder::STATUS_FAILED_SETUP,
|
||||
\Model_ClientOrder::STATUS_FAILED_RENEW,
|
||||
\Model_ClientOrder::STATUS_ACTIVE,
|
||||
\Model_ClientOrder::STATUS_CANCELED,
|
||||
\Model_ClientOrder::STATUS_SUSPENDED,
|
||||
ClientOrderStatusEnum::PENDING_SETUP->value,
|
||||
ClientOrderStatusEnum::FAILED_SETUP->value,
|
||||
ClientOrderStatusEnum::FAILED_RENEW->value,
|
||||
ClientOrderStatusEnum::ACTIVE->value,
|
||||
ClientOrderStatusEnum::CANCELED->value,
|
||||
ClientOrderStatusEnum::SUSPENDED->value,
|
||||
];
|
||||
|
||||
protected ?\Pimple\Container $di = null;
|
||||
@@ -117,7 +119,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
|
||||
';
|
||||
$this->di['db']->exec($sql);
|
||||
$this->di['dbal']->executeStatement($sql);
|
||||
|
||||
// default config values
|
||||
$extensionService->setConfig(['ext' => 'mod_massmailer', 'limit' => '2', 'interval' => '10', 'test_client_id' => 1]);
|
||||
|
||||
@@ -13,6 +13,7 @@ declare(strict_types=1);
|
||||
namespace Box\Mod\News\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
@@ -21,6 +22,8 @@ use FOSSBilling\Interfaces\TimestampInterface;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class Post implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
@@ -57,12 +60,6 @@ class Post implements ApiArrayInterface, TimestampInterface
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $expiresAt = null;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE)]
|
||||
private \DateTime $createdAt;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE)]
|
||||
private \DateTime $updatedAt;
|
||||
|
||||
/** @todo Temporary until Admin entity is migrated */
|
||||
private array $adminData = [];
|
||||
|
||||
@@ -103,27 +100,13 @@ class Post implements ApiArrayInterface, TimestampInterface
|
||||
'publish_at' => $this->getPublishAt()?->format('Y-m-d H:i:s'),
|
||||
'published_at' => $this->getPublishedAt()?->format('Y-m-d H:i:s'),
|
||||
'expires_at' => $this->getExpiresAt()?->format('Y-m-d H:i:s'),
|
||||
'created_at' => $this->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
'author' => $this->adminData, /* @todo Doctrine: Replace with actual Admin entity and remove $adminData once it's migrated to Doctrine. */
|
||||
'excerpt' => $excerpt,
|
||||
];
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
$now = new \DateTime();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -169,16 +152,6 @@ class Post implements ApiArrayInterface, TimestampInterface
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): \DateTime
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function getPublishAt(): ?\DateTime
|
||||
{
|
||||
return $this->publishAt;
|
||||
@@ -256,16 +229,6 @@ class Post implements ApiArrayInterface, TimestampInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTime $createdAt): void
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(\DateTime $updatedAt): void
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
}
|
||||
|
||||
public function setPublishAt(?\DateTime $publishAt): self
|
||||
{
|
||||
$this->publishAt = $publishAt;
|
||||
|
||||
@@ -365,7 +365,7 @@ class Service implements InjectionAwareInterface
|
||||
$data['discount'] ??= 0;
|
||||
$data['title'] = $model->title;
|
||||
$data['meta'] = $this->di['db']->getAssoc('SELECT name, value FROM client_order_meta WHERE client_order_id = :id', [':id' => $model->id]);
|
||||
$data['active_tickets'] = $supportService->getActiveTicketsCountForOrder($model);
|
||||
$data['active_tickets'] = $supportService->getSupportTicketRepository()->countActiveTicketsForOrder((int) $model->id);
|
||||
$client = $this->di['db']->getExistingModelById('Client', $model->client_id, 'Client not found');
|
||||
$data['client'] = $clientService->toApiArray($client, false);
|
||||
|
||||
|
||||
@@ -941,7 +941,9 @@ test('toApiArray returns expected keys', function (): void {
|
||||
$clientService->shouldReceive('toApiArray')->atLeast()->once()->andReturn([]);
|
||||
|
||||
$supportService = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportService->shouldReceive('getActiveTicketsCountForOrder')->atLeast()->once()->andReturn(1);
|
||||
$supportTicketRepo = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
|
||||
$supportTicketRepo->shouldReceive('countActiveTicketsForOrder')->atLeast()->once()->andReturn(1);
|
||||
$supportService->shouldReceive('getSupportTicketRepository')->atLeast()->once()->andReturn($supportTicketRepo);
|
||||
|
||||
$dbMock = Mockery::mock(Box_Database::class);
|
||||
$dbMock->shouldReceive('toArray')->atLeast()->once()->andReturn([]);
|
||||
|
||||
@@ -13,11 +13,16 @@ declare(strict_types=1);
|
||||
namespace Box\Mod\Product\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
#[ORM\Entity(repositoryClass: \Box\Mod\Product\Repository\ProductRepository::class)]
|
||||
#[ORM\Table(name: 'product')]
|
||||
class Product
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class Product implements TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
@@ -90,12 +95,6 @@ class Product
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
|
||||
private ?string $config = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $createdAt = null;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $updatedAt = null;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $type = null;
|
||||
|
||||
@@ -368,30 +367,6 @@ class Product
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(?\DateTime $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(?\DateTime $updatedAt): self
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
|
||||
@@ -13,11 +13,16 @@ declare(strict_types=1);
|
||||
namespace Box\Mod\Product\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
#[ORM\Entity(repositoryClass: \Box\Mod\Product\Repository\ProductCategoryRepository::class)]
|
||||
#[ORM\Table(name: 'product_category')]
|
||||
class ProductCategory
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ProductCategory implements TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
@@ -33,12 +38,6 @@ class ProductCategory
|
||||
#[ORM\Column(name: 'icon_url', type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $iconUrl = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $createdAt = null;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $updatedAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -79,28 +78,4 @@ class ProductCategory
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(?\DateTime $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(?\DateTime $updatedAt): self
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ declare(strict_types=1);
|
||||
namespace Box\Mod\Product\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
@@ -21,6 +22,8 @@ use FOSSBilling\Interfaces\TimestampInterface;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class Promo implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
final public const string ABSOLUTE = 'absolute';
|
||||
final public const string PERCENTAGE = 'percentage';
|
||||
|
||||
@@ -75,12 +78,6 @@ class Promo implements ApiArrayInterface, TimestampInterface
|
||||
#[ORM\Column(name: 'end_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $endAt = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $createdAt = null;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $updatedAt = null;
|
||||
|
||||
public function toApiArray(): array
|
||||
{
|
||||
return [
|
||||
@@ -100,25 +97,11 @@ class Promo implements ApiArrayInterface, TimestampInterface
|
||||
'client_groups' => $this->clientGroups,
|
||||
'start_at' => $this->startAt?->format('Y-m-d H:i:s'),
|
||||
'end_at' => $this->endAt?->format('Y-m-d H:i:s'),
|
||||
'created_at' => $this->createdAt?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->updatedAt?->format('Y-m-d H:i:s'),
|
||||
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
$now = new \DateTime();
|
||||
$this->createdAt ??= $now;
|
||||
$this->updatedAt = $now;
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -303,24 +286,4 @@ class Promo implements ApiArrayInterface, TimestampInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(?\DateTime $createdAt): void
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(?\DateTime $updatedAt): void
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ declare(strict_types=1);
|
||||
namespace Box\Mod\Product\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
@@ -21,6 +22,8 @@ use FOSSBilling\Interfaces\TimestampInterface;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class PromoRedemption implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
final public const string PHASE_CHECKOUT = 'checkout';
|
||||
final public const string PHASE_RENEWAL = 'renewal';
|
||||
final public const string STATUS_RESERVED = 'reserved';
|
||||
@@ -66,12 +69,6 @@ class PromoRedemption implements ApiArrayInterface, TimestampInterface
|
||||
#[ORM\Column(name: 'release_reason', type: \Doctrine\DBAL\Types\Types::STRING, length: 100, nullable: true)]
|
||||
private ?string $releaseReason = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $createdAt = null;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $updatedAt = null;
|
||||
|
||||
public function toApiArray(): array
|
||||
{
|
||||
return [
|
||||
@@ -87,25 +84,11 @@ class PromoRedemption implements ApiArrayInterface, TimestampInterface
|
||||
'committed_at' => $this->committedAt?->format('Y-m-d H:i:s'),
|
||||
'released_at' => $this->releasedAt?->format('Y-m-d H:i:s'),
|
||||
'release_reason' => $this->releaseReason,
|
||||
'created_at' => $this->createdAt?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->updatedAt?->format('Y-m-d H:i:s'),
|
||||
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
$now = new \DateTime();
|
||||
$this->createdAt ??= $now;
|
||||
$this->updatedAt = $now;
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -242,24 +225,4 @@ class PromoRedemption implements ApiArrayInterface, TimestampInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTime $createdAt): void
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTime
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(\DateTime $updatedAt): void
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ use Box\Mod\Product\Repository\PromoRedemptionRepository;
|
||||
use Box\Mod\Product\Repository\PromoRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use FOSSBilling\InjectionAwareInterface;
|
||||
use FOSSBilling\PaginationOptions;
|
||||
|
||||
@@ -425,9 +424,7 @@ class Service implements InjectionAwareInterface
|
||||
->setSlug($slug)
|
||||
->setType($type)
|
||||
->setSetup(self::SETUP_AFTER_PAYMENT)
|
||||
->setPriority((int) $priority + 10)
|
||||
->setUpdatedAt(new \DateTime())
|
||||
->setCreatedAt(new \DateTime());
|
||||
->setPriority((int) $priority + 10);
|
||||
|
||||
$this->di['em']->persist($model);
|
||||
$this->di['em']->flush();
|
||||
@@ -587,9 +584,7 @@ class Service implements InjectionAwareInterface
|
||||
->setSetup($setup ?? self::SETUP_AFTER_PAYMENT)
|
||||
->setIsAddon(true)
|
||||
->setIconUrl($iconUrl)
|
||||
->setDescription($description)
|
||||
->setUpdatedAt(new \DateTime())
|
||||
->setCreatedAt(new \DateTime());
|
||||
->setDescription($description);
|
||||
|
||||
$this->di['em']->persist($model);
|
||||
$this->di['em']->flush();
|
||||
@@ -636,8 +631,7 @@ class Service implements InjectionAwareInterface
|
||||
$productCategory
|
||||
->setTitle($title)
|
||||
->setIconUrl($icon_url)
|
||||
->setDescription($description)
|
||||
->setUpdatedAt(new \DateTime());
|
||||
->setDescription($description);
|
||||
$this->di['em']->flush();
|
||||
|
||||
$this->di['logger']->info('Updated product category #%s', $productCategory->getId());
|
||||
@@ -650,9 +644,7 @@ class Service implements InjectionAwareInterface
|
||||
$model = (new ProductCategory())
|
||||
->setTitle($title)
|
||||
->setDescription($description)
|
||||
->setIconUrl($icon_url)
|
||||
->setUpdatedAt(new \DateTime())
|
||||
->setCreatedAt(new \DateTime());
|
||||
->setIconUrl($icon_url);
|
||||
$this->di['em']->persist($model);
|
||||
$this->di['em']->flush();
|
||||
$id = $model->getId();
|
||||
@@ -2087,24 +2079,7 @@ class Service implements InjectionAwareInterface
|
||||
*/
|
||||
private function paginateMappedQuery(QueryBuilder $qb, PaginationOptions $pagination, callable $mapper): array
|
||||
{
|
||||
$offset = ($pagination->page - 1) * $pagination->perPage;
|
||||
$qb->setFirstResult($offset)
|
||||
->setMaxResults($pagination->perPage);
|
||||
$paginator = new DoctrinePaginator($qb, true);
|
||||
$total = count($paginator);
|
||||
|
||||
$list = [];
|
||||
foreach ($paginator as $entity) {
|
||||
$list[] = $mapper($entity);
|
||||
}
|
||||
|
||||
return [
|
||||
'pages' => $total > 0 ? (int) ceil($total / $pagination->perPage) : 0,
|
||||
'page' => $pagination->page,
|
||||
'per_page' => $pagination->perPage,
|
||||
'total' => $total,
|
||||
'list' => $list,
|
||||
];
|
||||
return $this->di['pager']->paginateMappedQuery($qb, $pagination, $mapper);
|
||||
}
|
||||
|
||||
public function getProductDiscount(Product $product, Promo $promo, ?array $config = null)
|
||||
|
||||
@@ -370,7 +370,8 @@ class Service implements InjectionAwareInterface
|
||||
$ticketModel = $supportTicketService->getTicketById((int) $params['id']);
|
||||
$ticket = $supportTicketService->toApiArray($ticketModel, true);
|
||||
|
||||
$helpdeskModel = isset($ticketModel->support_helpdesk_id) && $ticketModel->support_helpdesk_id ? $di['em']->getRepository(Helpdesk::class)->find((int) $ticketModel->support_helpdesk_id) : null;
|
||||
$helpdeskId = $ticketModel->getSupportHelpdeskId();
|
||||
$helpdeskModel = $helpdeskId !== null ? $di['em']->getRepository(Helpdesk::class)->find($helpdeskId) : null;
|
||||
$emailService = $di['mod_service']('email');
|
||||
if ($helpdeskModel instanceof Helpdesk && !empty($helpdeskModel->getEmail())) {
|
||||
$email = [];
|
||||
|
||||
@@ -309,7 +309,7 @@ test('onAfterClientReplyTicket sends email notification', function (): void {
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -343,7 +343,7 @@ test('onAfterClientReplyTicket handles email exception', function (): void {
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -377,7 +377,7 @@ test('onAfterClientCloseTicket sends email notification', function (): void {
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -411,7 +411,7 @@ test('onAfterClientCloseTicket handles email exception', function (): void {
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -445,7 +445,7 @@ test('onAfterClientOpenTicket sends guest email notification', function (): void
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -479,7 +479,7 @@ test('onAfterClientOpenTicket handles guest email exception', function (): void
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -513,7 +513,7 @@ test('onAfterClientReplyTicket sends guest email notification', function (): voi
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -547,7 +547,7 @@ test('onAfterClientReplyTicket handles guest email exception', function (): void
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -644,7 +644,7 @@ test('onAfterClientCloseTicket handles guest email exception', function (): void
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -675,9 +675,11 @@ test('onAfterClientCloseTicket handles guest email exception', function (): void
|
||||
test('onAfterClientOpenTicket sends mod_staff_ticket_open email', function (): void {
|
||||
$di = container();
|
||||
|
||||
$ticketModel = new Model_SupportTicket();
|
||||
$ticketModel->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ticketModel->support_helpdesk_id = 1;
|
||||
$ticketModel = new Box\Mod\Support\Entity\SupportTicket();
|
||||
\Tests\Helpers\setEntityId($ticketModel, 1);
|
||||
$helpdesk = new Helpdesk();
|
||||
\Tests\Helpers\setEntityId($helpdesk, 1);
|
||||
$ticketModel->setSupportHelpdesk($helpdesk);
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
@@ -735,9 +737,11 @@ test('onAfterClientOpenTicket sends mod_staff_ticket_open email', function (): v
|
||||
test('onAfterClientOpenTicket sends mod_support_helpdesk_ticket_open email', function (): void {
|
||||
$di = container();
|
||||
|
||||
$ticketModel = new Model_SupportTicket();
|
||||
$ticketModel->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ticketModel->support_helpdesk_id = 1;
|
||||
$ticketModel = new Box\Mod\Support\Entity\SupportTicket();
|
||||
\Tests\Helpers\setEntityId($ticketModel, 1);
|
||||
$helpdesk = new Helpdesk();
|
||||
\Tests\Helpers\setEntityId($helpdesk, 1);
|
||||
$ticketModel->setSupportHelpdesk($helpdesk);
|
||||
|
||||
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
|
||||
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
|
||||
@@ -21,6 +21,7 @@ use Box\Mod\Support\Entity\CannedResponseCategory;
|
||||
use Box\Mod\Support\Entity\Helpdesk;
|
||||
use Box\Mod\Support\Entity\KbArticle;
|
||||
use Box\Mod\Support\Entity\KbArticleCategory;
|
||||
use Box\Mod\Support\Entity\SupportTicket;
|
||||
use FOSSBilling\PaginationOptions;
|
||||
use FOSSBilling\Validation\Api\RequiredParams;
|
||||
|
||||
@@ -37,15 +38,13 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('support', 'view');
|
||||
|
||||
[$sql, $bindings] = $this->getService()->getSearchQuery($data);
|
||||
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $bindings, PaginationOptions::fromArray($data));
|
||||
$repo = $this->getService()->getSupportTicketRepository();
|
||||
|
||||
foreach ($pager['list'] as $key => $ticketArr) {
|
||||
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $ticketArr['id'], 'Ticket not found');
|
||||
$pager['list'][$key] = $this->getService()->toApiArray($ticket, true, $this->getIdentity());
|
||||
}
|
||||
|
||||
return $pager;
|
||||
return $this->getDi()['pager']->paginateMappedQuery(
|
||||
$repo->getSearchQueryBuilder($data),
|
||||
PaginationOptions::fromArray($data),
|
||||
fn (SupportTicket $ticket): array => $this->getService()->toApiArray($ticket, false, $this->getIdentity()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +55,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('support', 'view');
|
||||
|
||||
$model = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
|
||||
$model = $this->getService()->getTicketById((int) $data['id']);
|
||||
|
||||
return $this->getService()->toApiArray($model, true, $this->getIdentity());
|
||||
}
|
||||
@@ -74,7 +73,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('support', 'manage_tickets');
|
||||
|
||||
$model = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
|
||||
$model = $this->getService()->getTicketById((int) $data['id']);
|
||||
|
||||
// Sanitize subject if provided
|
||||
if (isset($data['subject'])) {
|
||||
@@ -92,7 +91,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('support', 'manage_tickets');
|
||||
|
||||
$model = $this->getDi()['db']->getExistingModelById('SupportTicketMessage', $data['id'], 'Ticket message not found');
|
||||
$model = $this->getService()->getTicketMessageById((int) $data['id']);
|
||||
|
||||
return $this->getService()->ticketMessageUpdate($model, $data['content']);
|
||||
}
|
||||
@@ -105,7 +104,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('support', 'manage_tickets');
|
||||
|
||||
$model = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
|
||||
$model = $this->getService()->getTicketById((int) $data['id']);
|
||||
|
||||
return $this->getService()->rm($model);
|
||||
}
|
||||
@@ -122,7 +121,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
|
||||
$data['content'] = \FOSSBilling\Tools::sanitizeMarkdownContent($data['content']);
|
||||
|
||||
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
|
||||
$ticket = $this->getService()->getTicketById((int) $data['id']);
|
||||
|
||||
return $this->getService()->ticketReply($ticket, $this->getIdentity(), $data['content']);
|
||||
}
|
||||
@@ -135,9 +134,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('support', 'manage_tickets');
|
||||
|
||||
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
|
||||
$ticket = $this->getService()->getTicketById((int) $data['id']);
|
||||
|
||||
if ($ticket->status == \Model_SupportTicket::CLOSED) {
|
||||
if ($ticket->getStatus() === SupportTicket::STATUS_CLOSED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -159,6 +158,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
|
||||
$data['content'] = \FOSSBilling\Tools::sanitizeMarkdownContent($data['content']);
|
||||
|
||||
/** @todo Doctrine: use Client entity once Client is migrated */
|
||||
$client = $this->getDi()['db']->getExistingModelById('Client', $data['client_id'], 'Client not found');
|
||||
|
||||
/** @var \Box\Mod\Support\Repository\HelpdeskRepository $repo */
|
||||
@@ -186,9 +186,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
$expiredArr = $this->getService()->getExpired();
|
||||
|
||||
foreach ($expiredArr as $ticketArr) {
|
||||
$ticketModel = $this->getDi()['db']->getExistingModelById('SupportTicket', $ticketArr['id'], 'Ticket not found');
|
||||
$ticketModel = $this->getService()->getTicketById((int) $ticketArr['id']);
|
||||
if (!$this->getService()->autoClose($ticketModel)) {
|
||||
$this->getDi()['logger']->info('Ticket %s was not closed', $ticketModel->id);
|
||||
$this->getDi()['logger']->info('Ticket %s was not closed', $ticketModel->getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +542,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('support', 'manage_tickets');
|
||||
|
||||
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['ticket_id'], 'Ticket not found');
|
||||
$ticket = $this->getService()->getTicketById((int) $data['ticket_id']);
|
||||
|
||||
return $this->getService()->noteCreate($ticket, $this->getIdentity(), $data['note']);
|
||||
}
|
||||
@@ -557,7 +557,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('support', 'manage_tickets');
|
||||
|
||||
$model = $this->getDi()['db']->getExistingModelById('SupportTicketNote', $data['id'], 'Note not found');
|
||||
$model = $this->getService()->getTicketNoteById((int) $data['id']);
|
||||
|
||||
return $this->getService()->noteRm($model);
|
||||
}
|
||||
@@ -572,7 +572,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
{
|
||||
$this->checkPermissions('support', 'manage_tickets');
|
||||
|
||||
$model = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
|
||||
$model = $this->getService()->getTicketById((int) $data['id']);
|
||||
|
||||
return $this->getService()->ticketTaskComplete($model);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ declare(strict_types=1);
|
||||
namespace Box\Mod\Support\Api;
|
||||
|
||||
use Box\Mod\Support\Entity\Helpdesk;
|
||||
use Box\Mod\Support\Entity\SupportTicket;
|
||||
use FOSSBilling\PaginationOptions;
|
||||
use FOSSBilling\Validation\Api\RequiredParams;
|
||||
|
||||
@@ -34,13 +35,12 @@ class Client extends \FOSSBilling\Api\AbstractApi
|
||||
$identity = $this->getIdentity();
|
||||
$data['client_id'] = $identity->id;
|
||||
|
||||
[$sql, $bindings] = $this->getService()->getSearchQuery($data);
|
||||
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $bindings, PaginationOptions::fromArray($data));
|
||||
|
||||
foreach ($pager['list'] as $key => $ticketArr) {
|
||||
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $ticketArr['id'], 'Ticket not found');
|
||||
$pager['list'][$key] = $this->getService()->toApiArray($ticket, true, $this->getIdentity());
|
||||
}
|
||||
$repo = $this->getService()->getSupportTicketRepository();
|
||||
$pager = $this->getDi()['pager']->paginateMappedQuery(
|
||||
$repo->getSearchQueryBuilder($data),
|
||||
PaginationOptions::fromArray($data),
|
||||
fn (SupportTicket $ticket): array => $this->getService()->toApiArray($ticket, true, $this->getIdentity()),
|
||||
);
|
||||
|
||||
return $pager;
|
||||
}
|
||||
@@ -112,9 +112,9 @@ class Client extends \FOSSBilling\Api\AbstractApi
|
||||
':id' => $data['id'],
|
||||
':client_id' => $client->id,
|
||||
];
|
||||
$ticket = $this->getDi()['db']->findOne('SupportTicket', 'id = :id AND client_id = :client_id', $bindings);
|
||||
$ticket = $this->getService()->getSupportTicketRepository()->findOneByClient((int) $client->id, (int) $data['id']);
|
||||
|
||||
if (!$ticket instanceof \Model_SupportTicket) {
|
||||
if (!$ticket instanceof SupportTicket) {
|
||||
throw new \FOSSBilling\InformationException('Ticket not found');
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Box\Mod\Support\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
@@ -49,6 +51,17 @@ class Helpdesk implements ApiArrayInterface, TimestampInterface
|
||||
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $updatedAt = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SupportTicket>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'helpdesk', targetEntity: SupportTicket::class)]
|
||||
private Collection $tickets;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tickets = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function toApiArray(\Model_Admin|\Model_Client|\Model_Guest|null $identity = null): array
|
||||
{
|
||||
if ($identity instanceof \Model_Admin) {
|
||||
@@ -169,4 +182,12 @@ class Helpdesk implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SupportTicket>
|
||||
*/
|
||||
public function getTickets(): Collection
|
||||
{
|
||||
return $this->tickets;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Support\Entity;
|
||||
|
||||
use Box\Mod\Support\Repository\SupportTicketRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
/**
|
||||
* A support ticket. Cross-module fields (Client, Helpdesk FK) are stored as
|
||||
* plain nullable IDs to keep this module decoupled from the Client migration
|
||||
* and from the (already-migrated) Helpdesk entity.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SupportTicketRepository::class)]
|
||||
#[ORM\Table(name: 'support_ticket')]
|
||||
#[ORM\Index(name: 'idx_ticket_status', columns: ['status'])]
|
||||
#[ORM\Index(name: 'idx_ticket_client', columns: ['client_id'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SupportTicket implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
public const STATUS_OPEN = 'open';
|
||||
public const STATUS_ONHOLD = 'on_hold';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
public const REL_TYPE_ORDER = 'order';
|
||||
|
||||
public const REL_STATUS_PENDING = 'pending';
|
||||
public const REL_STATUS_COMPLETE = 'complete';
|
||||
|
||||
public const REL_TASK_CANCEL = 'cancel';
|
||||
public const REL_TASK_UPGRADE = 'upgrade';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Helpdesk::class, inversedBy: 'tickets')]
|
||||
#[ORM\JoinColumn(name: 'support_helpdesk_id', referencedColumnName: 'id', nullable: true)]
|
||||
private ?Helpdesk $helpdesk = null;
|
||||
|
||||
#[ORM\Column(name: 'client_id', type: Types::INTEGER, nullable: true)]
|
||||
private ?int $clientId = null;
|
||||
|
||||
#[ORM\Column(name: 'access_hash', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $accessHash = null;
|
||||
|
||||
#[ORM\Column(name: 'author_name', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $authorName = null;
|
||||
|
||||
#[ORM\Column(name: 'author_email', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $authorEmail = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, nullable: true, options: ['default' => 100])]
|
||||
private ?int $priority = 100;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $subject = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 30, nullable: true, options: ['default' => self::STATUS_OPEN])]
|
||||
private ?string $status = self::STATUS_OPEN;
|
||||
|
||||
#[ORM\Column(name: 'rel_type', type: Types::STRING, length: 100, nullable: true)]
|
||||
private ?string $relType = null;
|
||||
|
||||
#[ORM\Column(name: 'rel_id', type: Types::INTEGER, nullable: true)]
|
||||
private ?int $relId = null;
|
||||
|
||||
#[ORM\Column(name: 'rel_task', type: Types::STRING, length: 100, nullable: true)]
|
||||
private ?string $relTask = null;
|
||||
|
||||
#[ORM\Column(name: 'rel_new_value', type: Types::TEXT, nullable: true)]
|
||||
private ?string $relNewValue = null;
|
||||
|
||||
#[ORM\Column(name: 'rel_status', type: Types::STRING, length: 100, nullable: true)]
|
||||
private ?string $relStatus = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SupportTicketMessage>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'ticket', targetEntity: SupportTicketMessage::class)]
|
||||
#[ORM\OrderBy(['id' => 'ASC'])]
|
||||
private Collection $messages;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SupportTicketNote>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'ticket', targetEntity: SupportTicketNote::class)]
|
||||
private Collection $notes;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->messages = new ArrayCollection();
|
||||
$this->notes = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function toApiArray(\Model_Admin|\Model_Client|\Model_Guest|null $identity = null): array
|
||||
{
|
||||
$data = [
|
||||
'id' => $this->id,
|
||||
'support_helpdesk_id' => $this->helpdesk?->getId(),
|
||||
'client_id' => $this->clientId,
|
||||
'access_hash' => $this->accessHash,
|
||||
'author_name' => $this->authorName,
|
||||
'author_email' => $this->authorEmail,
|
||||
'priority' => $this->priority,
|
||||
'subject' => $this->subject,
|
||||
'status' => $this->status,
|
||||
'rel_type' => $this->relType,
|
||||
'rel_id' => $this->relId,
|
||||
'rel_task' => $this->relTask,
|
||||
'rel_new_value' => $this->relNewValue,
|
||||
'rel_status' => $this->relStatus,
|
||||
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if (!empty($data['access_hash'])) {
|
||||
$data['hash'] = $data['access_hash'];
|
||||
}
|
||||
|
||||
if ($identity instanceof \Model_Admin) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// @deprecated 0.9.0 Use author.id/name/email instead of client_id/author_name/author_email.
|
||||
unset(
|
||||
$data['support_helpdesk_id'],
|
||||
$data['client_id'],
|
||||
$data['priority'],
|
||||
$data['access_hash'],
|
||||
$data['rel_type'],
|
||||
$data['rel_id'],
|
||||
$data['rel_task'],
|
||||
$data['rel_new_value'],
|
||||
$data['rel_status'],
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket belongs to a guest when it has no client but does carry a
|
||||
* public access hash (used by the guest/public ticket flow).
|
||||
*/
|
||||
public function isGuestTicket(): bool
|
||||
{
|
||||
return $this->clientId === null && $this->accessHash !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a closed ticket may be re-opened.
|
||||
*
|
||||
* Non-closed tickets can always be re-opened. Closed tickets delegate to
|
||||
* their helpdesk's `can_reopen` flag. Throws if the helpdesk cannot be
|
||||
* resolved, matching the legacy behaviour.
|
||||
*/
|
||||
public function canBeReopen(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_CLOSED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$helpdesk = $this->helpdesk;
|
||||
if (!$helpdesk instanceof Helpdesk) {
|
||||
throw new \FOSSBilling\Exception('Helpdesk invalid');
|
||||
}
|
||||
|
||||
return $helpdesk->canReopen();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHelpdesk(): ?Helpdesk
|
||||
{
|
||||
return $this->helpdesk;
|
||||
}
|
||||
|
||||
public function setHelpdesk(?Helpdesk $helpdesk): self
|
||||
{
|
||||
$this->helpdesk = $helpdesk;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupportHelpdeskId(): ?int
|
||||
{
|
||||
return $this->helpdesk?->getId();
|
||||
}
|
||||
|
||||
public function setSupportHelpdesk(?Helpdesk $helpdesk): self
|
||||
{
|
||||
$this->helpdesk = $helpdesk;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientId(): ?int
|
||||
{
|
||||
return $this->clientId;
|
||||
}
|
||||
|
||||
public function setClientId(?int $clientId): self
|
||||
{
|
||||
$this->clientId = $clientId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccessHash(): ?string
|
||||
{
|
||||
return $this->accessHash;
|
||||
}
|
||||
|
||||
public function setAccessHash(?string $accessHash): self
|
||||
{
|
||||
$this->accessHash = $accessHash;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthorName(): ?string
|
||||
{
|
||||
return $this->authorName;
|
||||
}
|
||||
|
||||
public function setAuthorName(?string $authorName): self
|
||||
{
|
||||
$this->authorName = $authorName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthorEmail(): ?string
|
||||
{
|
||||
return $this->authorEmail;
|
||||
}
|
||||
|
||||
public function setAuthorEmail(?string $authorEmail): self
|
||||
{
|
||||
$this->authorEmail = $authorEmail;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPriority(): ?int
|
||||
{
|
||||
return $this->priority;
|
||||
}
|
||||
|
||||
public function setPriority(?int $priority): self
|
||||
{
|
||||
$this->priority = $priority;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubject(): ?string
|
||||
{
|
||||
return $this->subject;
|
||||
}
|
||||
|
||||
public function setSubject(?string $subject): self
|
||||
{
|
||||
$this->subject = $subject;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): ?string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(?string $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRelType(): ?string
|
||||
{
|
||||
return $this->relType;
|
||||
}
|
||||
|
||||
public function setRelType(?string $relType): self
|
||||
{
|
||||
$this->relType = $relType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRelId(): ?int
|
||||
{
|
||||
return $this->relId;
|
||||
}
|
||||
|
||||
public function setRelId(?int $relId): self
|
||||
{
|
||||
$this->relId = $relId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRelTask(): ?string
|
||||
{
|
||||
return $this->relTask;
|
||||
}
|
||||
|
||||
public function setRelTask(?string $relTask): self
|
||||
{
|
||||
$this->relTask = $relTask;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRelNewValue(): ?string
|
||||
{
|
||||
return $this->relNewValue;
|
||||
}
|
||||
|
||||
public function setRelNewValue(?string $relNewValue): self
|
||||
{
|
||||
$this->relNewValue = $relNewValue;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRelStatus(): ?string
|
||||
{
|
||||
return $this->relStatus;
|
||||
}
|
||||
|
||||
public function setRelStatus(?string $relStatus): self
|
||||
{
|
||||
$this->relStatus = $relStatus;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SupportTicketMessage>
|
||||
*/
|
||||
public function getMessages(): Collection
|
||||
{
|
||||
return $this->messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SupportTicketNote>
|
||||
*/
|
||||
public function getNotes(): Collection
|
||||
{
|
||||
return $this->notes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Support\Entity;
|
||||
|
||||
use Box\Mod\Support\Repository\SupportTicketMessageRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
/**
|
||||
* A message in a support ticket thread. One ticket has many messages, ordered
|
||||
* by `id` ASC (FIFO). The owning ticket is a `ManyToOne` association since
|
||||
* both entities live in the Support module.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SupportTicketMessageRepository::class)]
|
||||
#[ORM\Table(name: 'support_ticket_message')]
|
||||
#[ORM\Index(name: 'idx_msg_ticket', columns: ['support_ticket_id'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SupportTicketMessage implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SupportTicket::class, inversedBy: 'messages')]
|
||||
#[ORM\JoinColumn(name: 'support_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?SupportTicket $ticket = null;
|
||||
|
||||
#[ORM\Column(name: 'client_id', type: Types::INTEGER, nullable: true)]
|
||||
private ?int $clientId = null;
|
||||
|
||||
#[ORM\Column(name: 'admin_id', type: Types::INTEGER, nullable: true)]
|
||||
private ?int $adminId = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $content = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $attachment = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 45, nullable: true)]
|
||||
private ?string $ip = null;
|
||||
|
||||
public function toApiArray(\Model_Admin|\Model_Client|\Model_Guest|null $identity = null): array
|
||||
{
|
||||
if ($identity instanceof \Model_Admin) {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'support_ticket_id' => $this->ticket?->getId(),
|
||||
'client_id' => $this->clientId,
|
||||
'admin_id' => $this->adminId,
|
||||
'content' => $this->content,
|
||||
'attachment' => $this->attachment,
|
||||
'ip' => $this->ip,
|
||||
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'content' => $this->content,
|
||||
'attachment' => $this->attachment,
|
||||
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTicket(): ?SupportTicket
|
||||
{
|
||||
return $this->ticket;
|
||||
}
|
||||
|
||||
public function setTicket(?SupportTicket $ticket): self
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupportTicket(): ?SupportTicket
|
||||
{
|
||||
return $this->ticket;
|
||||
}
|
||||
|
||||
public function setSupportTicket(?SupportTicket $ticket): self
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupportTicketId(): ?int
|
||||
{
|
||||
return $this->ticket?->getId();
|
||||
}
|
||||
|
||||
public function getClientId(): ?int
|
||||
{
|
||||
return $this->clientId;
|
||||
}
|
||||
|
||||
public function setClientId(?int $clientId): self
|
||||
{
|
||||
$this->clientId = $clientId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAdminId(): ?int
|
||||
{
|
||||
return $this->adminId;
|
||||
}
|
||||
|
||||
public function setAdminId(?int $adminId): self
|
||||
{
|
||||
$this->adminId = $adminId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContent(): ?string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function setContent(?string $content): self
|
||||
{
|
||||
$this->content = $content;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAttachment(): ?string
|
||||
{
|
||||
return $this->attachment;
|
||||
}
|
||||
|
||||
public function setAttachment(?string $attachment): self
|
||||
{
|
||||
$this->attachment = $attachment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIp(): ?string
|
||||
{
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
public function setIp(?string $ip): self
|
||||
{
|
||||
$this->ip = $ip;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Support\Entity;
|
||||
|
||||
use Box\Mod\Support\Repository\SupportTicketNoteRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\TimestampTrait;
|
||||
use FOSSBilling\Interfaces\ApiArrayInterface;
|
||||
use FOSSBilling\Interfaces\TimestampInterface;
|
||||
|
||||
/**
|
||||
* An internal note on a support ticket — visible to staff but not to the
|
||||
* client. One ticket has many notes.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SupportTicketNoteRepository::class)]
|
||||
#[ORM\Table(name: 'support_ticket_note')]
|
||||
#[ORM\Index(name: 'idx_note_ticket', columns: ['support_ticket_id'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SupportTicketNote implements ApiArrayInterface, TimestampInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SupportTicket::class, inversedBy: 'notes')]
|
||||
#[ORM\JoinColumn(name: 'support_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?SupportTicket $ticket = null;
|
||||
|
||||
#[ORM\Column(name: 'admin_id', type: Types::INTEGER, nullable: true)]
|
||||
private ?int $adminId = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $note = null;
|
||||
|
||||
public function toApiArray(\Model_Admin|\Model_Client|\Model_Guest|null $identity = null): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'support_ticket_id' => $this->ticket?->getId(),
|
||||
'admin_id' => $this->adminId,
|
||||
'note' => $this->note,
|
||||
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTicket(): ?SupportTicket
|
||||
{
|
||||
return $this->ticket;
|
||||
}
|
||||
|
||||
public function setTicket(?SupportTicket $ticket): self
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupportTicket(): ?SupportTicket
|
||||
{
|
||||
return $this->ticket;
|
||||
}
|
||||
|
||||
public function setSupportTicket(?SupportTicket $ticket): self
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupportTicketId(): ?int
|
||||
{
|
||||
return $this->ticket?->getId();
|
||||
}
|
||||
|
||||
public function getAdminId(): ?int
|
||||
{
|
||||
return $this->adminId;
|
||||
}
|
||||
|
||||
public function setAdminId(?int $adminId): self
|
||||
{
|
||||
$this->adminId = $adminId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNote(): ?string
|
||||
{
|
||||
return $this->note;
|
||||
}
|
||||
|
||||
public function setNote(?string $note): self
|
||||
{
|
||||
$this->note = $note;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Support\Repository;
|
||||
|
||||
use Box\Mod\Support\Entity\SupportTicketMessage;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class SupportTicketMessageRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* Find a single ticket message by id, throwing if it does not exist.
|
||||
*/
|
||||
public function findOneByIdOrFail(int $id): SupportTicketMessage
|
||||
{
|
||||
$message = $this->find($id);
|
||||
if (!$message instanceof SupportTicketMessage) {
|
||||
throw new \FOSSBilling\Exception('Ticket message not found');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all messages belonging to a ticket, oldest first.
|
||||
*
|
||||
* @return SupportTicketMessage[]
|
||||
*/
|
||||
public function findByTicketId(int $ticketId): array
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->andWhere('m.ticket = :tid')
|
||||
->setParameter('tid', $ticketId)
|
||||
->orderBy('m.id', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first (oldest) message on a ticket — used to show a preview
|
||||
* snippet in ticket listings. Returns null if the ticket has no messages.
|
||||
*/
|
||||
public function findFirstByTicketId(int $ticketId): ?SupportTicketMessage
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->andWhere('m.ticket = :tid')
|
||||
->setParameter('tid', $ticketId)
|
||||
->orderBy('m.id', 'ASC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count messages on a ticket — used to gate "first reply" notifications
|
||||
* and similar rules.
|
||||
*/
|
||||
public function countByTicketId(int $ticketId): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('m')
|
||||
->select('COUNT(m.id)')
|
||||
->andWhere('m.ticket = :tid')
|
||||
->setParameter('tid', $ticketId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a `[ticket_id => reply_count]` map for the supplied ticket ids.
|
||||
*
|
||||
* Implemented via DBAL because the calling code operates on raw rows (not
|
||||
* entities) in a performance-sensitive batch path, and a GROUP BY over the
|
||||
* entity manager would hydrate every message.
|
||||
*
|
||||
* @param list<int> $ticketIds
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function countRepliesByTicketIds(array $ticketIds): array
|
||||
{
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->getEntityManager()->getConnection()
|
||||
->fetchAllAssociative(
|
||||
'SELECT support_ticket_id, COUNT(id) AS counter
|
||||
FROM support_ticket_message
|
||||
WHERE support_ticket_id IN (:ids)
|
||||
GROUP BY support_ticket_id',
|
||||
['ids' => $ticketIds],
|
||||
['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER]
|
||||
);
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[(int) $row['support_ticket_id']] = (int) $row['counter'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the lowest message id for each supplied ticket id.
|
||||
*
|
||||
* Used by the batch ticket fetcher to pull a single "first message" per
|
||||
* ticket without hydrating the entire thread.
|
||||
*
|
||||
* @param list<int> $ticketIds
|
||||
*
|
||||
* @return array<int, int> `[ticket_id => first_message_id]`
|
||||
*/
|
||||
public function findFirstIdsByTicketIds(array $ticketIds): array
|
||||
{
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->getEntityManager()->getConnection()
|
||||
->fetchAllAssociative(
|
||||
'SELECT support_ticket_id, MIN(id) AS message_id
|
||||
FROM support_ticket_message
|
||||
WHERE support_ticket_id IN (:ids)
|
||||
GROUP BY support_ticket_id',
|
||||
['ids' => $ticketIds],
|
||||
['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER]
|
||||
);
|
||||
|
||||
$first = [];
|
||||
foreach ($rows as $row) {
|
||||
$first[(int) $row['support_ticket_id']] = (int) $row['message_id'];
|
||||
}
|
||||
|
||||
return $first;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Support\Repository;
|
||||
|
||||
use Box\Mod\Support\Entity\SupportTicketNote;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class SupportTicketNoteRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* Find a single ticket note by id, throwing if it does not exist.
|
||||
*/
|
||||
public function findOneByIdOrFail(int $id): SupportTicketNote
|
||||
{
|
||||
$note = $this->find($id);
|
||||
if (!$note instanceof SupportTicketNote) {
|
||||
throw new \FOSSBilling\Exception('Note not found');
|
||||
}
|
||||
|
||||
return $note;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SupportTicketNote[]
|
||||
*/
|
||||
public function findByTicketId(int $ticketId): array
|
||||
{
|
||||
return $this->createQueryBuilder('n')
|
||||
->andWhere('n.ticket = :tid')
|
||||
->setParameter('tid', $ticketId)
|
||||
->orderBy('n.id', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Support\Repository;
|
||||
|
||||
use Box\Mod\Support\Entity\SupportTicket;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class SupportTicketRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* Build a QueryBuilder for the admin ticket listing.
|
||||
*
|
||||
* Accepted keys in `$data`:
|
||||
* - `id` (int) exact id match
|
||||
* - `status` (string) exact status match
|
||||
* - `priority` (int) exact priority match
|
||||
* - `client_id` (int) filter by client
|
||||
* - `auth` (string) client or guest author filter
|
||||
* - `order_id` (int) filter by related order
|
||||
* - `subject` (string) LIKE on subject
|
||||
* - `content` (string) LIKE on ticket messages
|
||||
* - `name` (string) LIKE on author name
|
||||
* - `email` (string) LIKE on author email
|
||||
* - `support_helpdesk_id` (int) filter by helpdesk
|
||||
* - `helpdesk_id` (int) filter by helpdesk (legacy alias)
|
||||
* - `search` (string) LIKE on subject / author_email / author_name
|
||||
* - `date_from` (string) created_at lower bound (Y-m-d)
|
||||
* - `date_to` (string) created_at upper bound (Y-m-d)
|
||||
*/
|
||||
public function getSearchQueryBuilder(array $data = []): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('t')
|
||||
->orderBy('t.id', 'DESC');
|
||||
|
||||
if (!empty($data['id'])) {
|
||||
$qb->andWhere('t.id = :id')
|
||||
->setParameter('id', (int) $data['id']);
|
||||
}
|
||||
|
||||
if (!empty($data['status'])) {
|
||||
$qb->andWhere('t.status = :status')
|
||||
->setParameter('status', $data['status']);
|
||||
}
|
||||
|
||||
if (isset($data['priority']) && $data['priority'] !== '') {
|
||||
$qb->andWhere('t.priority = :priority')
|
||||
->setParameter('priority', (int) $data['priority']);
|
||||
}
|
||||
|
||||
if (!empty($data['client_id'])) {
|
||||
$qb->andWhere('t.clientId = :client_id')
|
||||
->setParameter('client_id', (int) $data['client_id']);
|
||||
}
|
||||
|
||||
if (($data['auth'] ?? null) === 'guest') {
|
||||
$qb->andWhere('t.clientId IS NULL')
|
||||
->andWhere('t.accessHash IS NOT NULL');
|
||||
} elseif (($data['auth'] ?? null) === 'client') {
|
||||
$qb->andWhere('t.clientId IS NOT NULL');
|
||||
}
|
||||
|
||||
if (!empty($data['name'])) {
|
||||
$qb->andWhere('t.authorName LIKE :author_name')
|
||||
->setParameter('author_name', '%' . $data['name'] . '%');
|
||||
}
|
||||
|
||||
if (!empty($data['email'])) {
|
||||
$qb->andWhere('t.authorEmail LIKE :author_email')
|
||||
->setParameter('author_email', '%' . $data['email'] . '%');
|
||||
}
|
||||
|
||||
if (!empty($data['subject'])) {
|
||||
$qb->andWhere('t.subject LIKE :filter_subject')
|
||||
->setParameter('filter_subject', '%' . $data['subject'] . '%');
|
||||
}
|
||||
|
||||
if (!empty($data['order_id'])) {
|
||||
$qb->andWhere('t.relType = :rel_type')
|
||||
->andWhere('t.relId = :rel_id')
|
||||
->setParameter('rel_type', SupportTicket::REL_TYPE_ORDER)
|
||||
->setParameter('rel_id', (int) $data['order_id']);
|
||||
}
|
||||
|
||||
if (!empty($data['content'])) {
|
||||
$qb->leftJoin('t.messages', 'filter_m')
|
||||
->andWhere('filter_m.content LIKE :filter_content')
|
||||
->setParameter('filter_content', '%' . $data['content'] . '%')
|
||||
->distinct();
|
||||
}
|
||||
|
||||
$helpdeskId = $data['support_helpdesk_id'] ?? $data['helpdesk_id'] ?? null;
|
||||
if ($helpdeskId !== null && $helpdeskId !== '') {
|
||||
$qb->andWhere('IDENTITY(t.helpdesk) = :helpdesk_id')
|
||||
->setParameter('helpdesk_id', (int) $helpdeskId);
|
||||
}
|
||||
|
||||
if (!empty($data['search'])) {
|
||||
if (is_numeric($data['search'])) {
|
||||
$qb->andWhere('t.id = :ticket_id')
|
||||
->setParameter('ticket_id', (int) $data['search']);
|
||||
} else {
|
||||
$search = '%' . $data['search'] . '%';
|
||||
$qb->leftJoin('t.messages', 'search_m')
|
||||
->andWhere('(search_m.content LIKE :search OR t.subject LIKE :search OR t.authorEmail LIKE :search OR t.authorName LIKE :search)')
|
||||
->setParameter('search', $search)
|
||||
->distinct();
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($data['date_from'])) {
|
||||
$qb->andWhere('t.createdAt >= :date_from')
|
||||
->setParameter('date_from', new \DateTime((string) $data['date_from'] . ' 00:00:00'));
|
||||
}
|
||||
|
||||
if (!empty($data['date_to'])) {
|
||||
$qb->andWhere('t.createdAt <= :date_to')
|
||||
->setParameter('date_to', new \DateTime((string) $data['date_to'] . ' 23:59:59'));
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single ticket by id, throwing if it does not exist.
|
||||
*/
|
||||
public function findOneByIdOrFail(int $id): SupportTicket
|
||||
{
|
||||
$ticket = $this->find($id);
|
||||
if (!$ticket instanceof SupportTicket) {
|
||||
throw new \FOSSBilling\Exception('Ticket not found');
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single ticket owned by the given client.
|
||||
*/
|
||||
public function findOneByClient(int $clientId, int $id): ?SupportTicket
|
||||
{
|
||||
return $this->findOneBy(['id' => $id, 'clientId' => $clientId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single ticket owned by the given client, throwing if it does not exist.
|
||||
*/
|
||||
public function findOneByClientOrFail(int $clientId, int $id): SupportTicket
|
||||
{
|
||||
$ticket = $this->findOneByClient($clientId, $id);
|
||||
if (!$ticket instanceof SupportTicket) {
|
||||
throw new \FOSSBilling\Exception('Ticket not found');
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single ticket by its public access hash, optionally restricted
|
||||
* to a null client (i.e. guest tickets).
|
||||
*/
|
||||
public function findOneByAccessHash(string $hash, ?int $clientId = null): ?SupportTicket
|
||||
{
|
||||
$qb = $this->createQueryBuilder('t')
|
||||
->andWhere('t.accessHash = :hash')
|
||||
->setParameter('hash', $hash);
|
||||
|
||||
if ($clientId === null) {
|
||||
$qb->andWhere('t.clientId IS NULL');
|
||||
} else {
|
||||
$qb->andWhere('t.clientId = :cid')
|
||||
->setParameter('cid', $clientId);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the N most recent tickets, used by the dashboard widget.
|
||||
*
|
||||
* @return SupportTicket[]
|
||||
*/
|
||||
public function findLatest(int $limit = 10): array
|
||||
{
|
||||
return $this->createQueryBuilder('t')
|
||||
->orderBy('t.id', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first unassigned ticket matching a `rel_type / rel_id / rel_task`
|
||||
* triple. Used by SupportTicket to coalesce duplicate automated tickets
|
||||
* (e.g. multiple "order cancel" requests for the same order).
|
||||
*/
|
||||
public function findOneUnassignedByRel(string $relType, int $relId, string $relTask): ?SupportTicket
|
||||
{
|
||||
return $this->createQueryBuilder('t')
|
||||
->andWhere('t.relType = :rel_type')
|
||||
->andWhere('t.relId = :rel_id')
|
||||
->andWhere('t.relTask = :rel_task')
|
||||
->andWhere('t.clientId IS NULL')
|
||||
->setParameter('rel_type', $relType)
|
||||
->setParameter('rel_id', $relId)
|
||||
->setParameter('rel_task', $relTask)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tickets whose status is in the supplied set.
|
||||
*
|
||||
* @param string ...$statuses One or more status values
|
||||
*/
|
||||
public function countByStatus(string ...$statuses): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('t')
|
||||
->select('COUNT(t.id)');
|
||||
|
||||
if ($statuses !== []) {
|
||||
$qb->andWhere('t.status IN (:statuses)')
|
||||
->setParameter('statuses', $statuses);
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a `[status => count]` map covering all tickets, grouped by status.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function countGroupedByStatus(): array
|
||||
{
|
||||
$result = $this->createQueryBuilder('t')
|
||||
->select('t.status, COUNT(t.id) AS cnt')
|
||||
->groupBy('t.status')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$counts = [];
|
||||
foreach ($result as $row) {
|
||||
$counts[(string) $row['status']] = (int) $row['cnt'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return on_hold tickets whose helpdesk close_after window has elapsed.
|
||||
*
|
||||
* Implemented via DBAL because the predicate mixes a column argument into
|
||||
* `DATE_ADD(... INTERVAL ... HOUR)`, which is awkward to express in DQL.
|
||||
* The result is returned as associative rows to stay consistent with the
|
||||
* previous RedBean-based return shape.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function findExpiredOnHold(\DateTimeInterface $now): array
|
||||
{
|
||||
$sql = 'SELECT st.*
|
||||
FROM support_ticket AS st
|
||||
LEFT JOIN support_helpdesk sh ON sh.id = st.support_helpdesk_id
|
||||
WHERE st.status = :status
|
||||
AND DATE_ADD(st.updated_at, INTERVAL sh.close_after HOUR) < :now
|
||||
ORDER BY st.id ASC';
|
||||
|
||||
return $this->getEntityManager()->getConnection()
|
||||
->fetchAllAssociative($sql, [
|
||||
'status' => SupportTicket::STATUS_ONHOLD,
|
||||
'now' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return raw ticket rows (associative arrays) for the supplied id list.
|
||||
*
|
||||
* Used by the performance-sensitive batch fetcher in
|
||||
* {@see \Box\Mod\Support\Service::getBatchForApi()}, which renders many
|
||||
* tickets at once for the client-area listing and intentionally avoids
|
||||
* hydrating entities for that path.
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function findBatchRowsByIds(array $ids): array
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->getEntityManager()->getConnection()
|
||||
->fetchAllAssociative(
|
||||
'SELECT * FROM support_ticket WHERE id IN (:ids)',
|
||||
['ids' => $ids],
|
||||
['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many active (open / on_hold) tickets reference a given order id.
|
||||
*/
|
||||
public function countActiveTicketsForOrder(int $orderId): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('t')
|
||||
->select('COUNT(t.id)')
|
||||
->andWhere('t.relType = :type')
|
||||
->andWhere('t.relId = :rid')
|
||||
->andWhere('t.status IN (:statuses)')
|
||||
->setParameter('type', SupportTicket::REL_TYPE_ORDER)
|
||||
->setParameter('rid', $orderId)
|
||||
->setParameter('statuses', [SupportTicket::STATUS_OPEN, SupportTicket::STATUS_ONHOLD])
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
}
|
||||
+444
-397
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Box\Mod\Support;
|
||||
|
||||
use Box\Mod\Support\Entity\SupportTicket;
|
||||
|
||||
class SupportTicketValidator
|
||||
{
|
||||
public static function validateTicketCreation(array $data): void
|
||||
@@ -16,28 +18,28 @@ class SupportTicketValidator
|
||||
self::validateRelTask($rel_task);
|
||||
self::validateRelStatus($rel_status);
|
||||
|
||||
if ($rel_task === \Model_SupportTicket::REL_TASK_UPGRADE) {
|
||||
if ($rel_task === SupportTicket::REL_TASK_UPGRADE) {
|
||||
self::validateUpgradeRequest($data, $rel_type);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function validateRelStatus(?string $rel_status): void
|
||||
{
|
||||
if ($rel_status !== null && !in_array($rel_status, \Model_SupportTicket::ALLOWED_REL_STATUSES, true)) {
|
||||
if ($rel_status !== null && !in_array($rel_status, [SupportTicket::REL_STATUS_PENDING, SupportTicket::REL_STATUS_COMPLETE], true)) {
|
||||
throw new \FOSSBilling\Exception('Invalid related status.');
|
||||
}
|
||||
}
|
||||
|
||||
protected static function validateRelType(?string $rel_type): void
|
||||
{
|
||||
if ($rel_type !== null && !in_array($rel_type, \Model_SupportTicket::ALLOWED_REL_TYPES, true)) {
|
||||
if ($rel_type !== null && !in_array($rel_type, [SupportTicket::REL_TYPE_ORDER], true)) {
|
||||
throw new \FOSSBilling\Exception('Invalid related type.');
|
||||
}
|
||||
}
|
||||
|
||||
protected static function validateRelTask(?string $rel_task): void
|
||||
{
|
||||
if ($rel_task !== null && !in_array($rel_task, \Model_SupportTicket::ALLOWED_REL_TASKS, true)) {
|
||||
if ($rel_task !== null && !in_array($rel_task, [SupportTicket::REL_TASK_CANCEL, SupportTicket::REL_TASK_UPGRADE], true)) {
|
||||
throw new \FOSSBilling\Exception('Invalid related task.');
|
||||
}
|
||||
}
|
||||
@@ -46,7 +48,7 @@ class SupportTicketValidator
|
||||
{
|
||||
$rel_id = $data['rel_id'] ?? null;
|
||||
$rel_new_value = $data['rel_new_value'] ?? null;
|
||||
if ($rel_type !== \Model_SupportTicket::REL_TYPE_ORDER || $rel_id === null || empty($rel_new_value)) {
|
||||
if ($rel_type !== SupportTicket::REL_TYPE_ORDER || $rel_id === null || empty($rel_new_value)) {
|
||||
throw new \FOSSBilling\Exception('You must provide both an order ID and a new product ID in order to request an upgrade.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use Box\Mod\Support\Entity\CannedResponseCategory;
|
||||
use Box\Mod\Support\Entity\Helpdesk;
|
||||
use Box\Mod\Support\Entity\KbArticle;
|
||||
use Box\Mod\Support\Entity\KbArticleCategory;
|
||||
use Box\Mod\Support\Entity\SupportTicket;
|
||||
use Box\Mod\Support\Repository\CannedResponseCategoryRepository;
|
||||
use Box\Mod\Support\Repository\CannedResponseRepository;
|
||||
use Box\Mod\Support\Repository\HelpdeskRepository;
|
||||
@@ -102,29 +103,23 @@ test('ticket get list', function (): void {
|
||||
];
|
||||
$paginatorMock = Mockery::mock(FOSSBilling\Pagination::class)->makePartial();
|
||||
$paginatorMock
|
||||
->shouldReceive('getPaginatedResultSet')
|
||||
->shouldReceive('paginateMappedQuery')
|
||||
->atLeast()->once()
|
||||
->andReturn($simpleResultArr);
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getSearchQuery')->atLeast()->once()
|
||||
->andReturn(['query', []]);
|
||||
$serviceMock
|
||||
->shouldReceive('toApiArray')
|
||||
->atLeast()->once()
|
||||
->andReturn([]);
|
||||
$qb = Mockery::mock(QueryBuilder::class);
|
||||
$repo = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
|
||||
$repo->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
|
||||
|
||||
$model = new Model_SupportTicket();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em->shouldReceive('getRepository')->with(SupportTicket::class)->andReturn($repo);
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getSupportTicketRepository')->andReturn($repo);
|
||||
|
||||
$di = container();
|
||||
$di['pager'] = $paginatorMock;
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = $em;
|
||||
|
||||
$api->setDi($di);
|
||||
|
||||
@@ -138,18 +133,14 @@ test('ticket get list', function (): void {
|
||||
|
||||
test('ticket get', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new SupportTicket());
|
||||
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
@@ -164,18 +155,14 @@ test('ticket get', function (): void {
|
||||
|
||||
test('ticket update', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new SupportTicket());
|
||||
$serviceMock->shouldReceive('ticketUpdate')->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
@@ -190,18 +177,14 @@ test('ticket update', function (): void {
|
||||
|
||||
test('ticket message update', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicketMessage());
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getTicketMessageById')->atLeast()->once()
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicketMessage());
|
||||
$serviceMock->shouldReceive('ticketMessageUpdate')->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
@@ -217,18 +200,14 @@ test('ticket message update', function (): void {
|
||||
|
||||
test('ticket delete', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new SupportTicket());
|
||||
$serviceMock->shouldReceive('rm')->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
@@ -243,18 +222,14 @@ test('ticket delete', function (): void {
|
||||
|
||||
test('ticket reply', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new SupportTicket());
|
||||
$serviceMock->shouldReceive('ticketReply')->atLeast()->once()
|
||||
->andReturn(1);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
@@ -271,21 +246,16 @@ test('ticket reply', function (): void {
|
||||
|
||||
test('ticket close', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$ticket = new Model_SupportTicket();
|
||||
$ticket->loadBean(new Tests\Helpers\DummyBean());
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
$ticket = new SupportTicket();
|
||||
Tests\Helpers\setEntityId($ticket, 1);
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
$serviceMock->shouldReceive('closeTicket')->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
@@ -301,22 +271,16 @@ test('ticket close', function (): void {
|
||||
|
||||
test('ticket close already closed', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$ticket = new Model_SupportTicket();
|
||||
$ticket->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ticket->status = Model_SupportTicket::CLOSED;
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
$ticket = new SupportTicket();
|
||||
Tests\Helpers\setEntityId($ticket, 1);
|
||||
$ticket->setStatus(SupportTicket::STATUS_CLOSED);
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('closeTicket')
|
||||
;
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
$serviceMock->shouldReceive('closeTicket');
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
@@ -374,28 +338,21 @@ test('ticket create', function (): void {
|
||||
|
||||
test('batch ticket auto close', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$ticket = new SupportTicket();
|
||||
Tests\Helpers\setEntityId($ticket, 1);
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getExpired')->atLeast()->once()
|
||||
->andReturn([['id' => 1], ['id' => 2]]);
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
$serviceMock->shouldReceive('autoClose')->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$ticket = new Model_SupportTicket();
|
||||
$ticket->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ticket->id = 1;
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$di['logger'] = $this->createStub('\Box_Log');
|
||||
$api->setDi($di);
|
||||
$api->setService($serviceMock);
|
||||
|
||||
$result = $api->batch_ticket_auto_close([]);
|
||||
|
||||
@@ -404,27 +361,21 @@ test('batch ticket auto close', function (): void {
|
||||
|
||||
test('batch ticket auto close not closed', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$ticket = new Model_SupportTicket();
|
||||
$ticket->loadBean(new Tests\Helpers\DummyBean());
|
||||
$ticket->id = 1;
|
||||
$ticket = new SupportTicket();
|
||||
Tests\Helpers\setEntityId($ticket, 1);
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getExpired')->atLeast()->once()
|
||||
->andReturn([['id' => 1], ['id' => 2]]);
|
||||
$serviceMock->shouldReceive('autoClose')->atLeast()->once()
|
||||
;
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
$serviceMock->shouldReceive('autoClose')->atLeast()->once();
|
||||
|
||||
$api->setService($serviceMock);
|
||||
$di = container();
|
||||
$di['logger'] = $this->createStub('\Box_Log');
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$result = $api->batch_ticket_auto_close([]);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
@@ -433,9 +384,9 @@ test('batch ticket auto close not closed', function (): void {
|
||||
test('ticket get statuses', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$statuses = [
|
||||
Model_SupportTicket::OPENED => 'Open',
|
||||
Model_SupportTicket::ONHOLD => 'On hold',
|
||||
Model_SupportTicket::CLOSED => 'Closed',
|
||||
SupportTicket::STATUS_OPEN => 'Open',
|
||||
SupportTicket::STATUS_ONHOLD => 'On hold',
|
||||
SupportTicket::STATUS_CLOSED => 'Closed',
|
||||
];
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getStatuses')
|
||||
@@ -453,9 +404,9 @@ test('ticket get statuses', function (): void {
|
||||
test('ticket get statuses titles set', function (): void {
|
||||
$api = new Box\Mod\Support\Api\Admin();
|
||||
$statuses = [
|
||||
Model_SupportTicket::OPENED => 'Open',
|
||||
Model_SupportTicket::ONHOLD => 'On hold',
|
||||
Model_SupportTicket::CLOSED => 'Closed',
|
||||
SupportTicket::STATUS_OPEN => 'Open',
|
||||
SupportTicket::STATUS_ONHOLD => 'On hold',
|
||||
SupportTicket::STATUS_CLOSED => 'Closed',
|
||||
];
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getStatuses')->atLeast()->once()
|
||||
@@ -910,15 +861,10 @@ test('note create', function (): void {
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('noteCreate')->atLeast()->once()
|
||||
->andReturn(1);
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new SupportTicket());
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
@@ -938,15 +884,10 @@ test('note delete', function (): void {
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('noteRm')->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicketNote());
|
||||
$serviceMock->shouldReceive('getTicketNoteById')->atLeast()->once()
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicketNote());
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
@@ -965,15 +906,10 @@ test('task complete', function (): void {
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('ticketTaskComplete')->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
|
||||
->andReturn(new SupportTicket());
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$api->setDi($di);
|
||||
|
||||
$api->setService($serviceMock);
|
||||
|
||||
@@ -25,29 +25,21 @@ test('ticket get list', function (): void {
|
||||
];
|
||||
$paginatorMock = Mockery::mock(FOSSBilling\Pagination::class)->makePartial();
|
||||
$paginatorMock
|
||||
->shouldReceive('getPaginatedResultSet')
|
||||
->shouldReceive('paginateMappedQuery')
|
||||
->atLeast()->once()
|
||||
->andReturn($simpleResultArr);
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('getSearchQuery')->atLeast()->once()
|
||||
->andReturn(['query', []]);
|
||||
$serviceMock
|
||||
->shouldReceive('toApiArray')
|
||||
->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
$model = new Model_SupportTicket();
|
||||
$model->loadBean(new Tests\Helpers\DummyBean());
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('getExistingModelById')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$repo = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
|
||||
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
|
||||
$repo->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
|
||||
|
||||
$serviceMock->shouldReceive('getSupportTicketRepository')->andReturn($repo);
|
||||
|
||||
$di = container();
|
||||
$di['pager'] = $paginatorMock;
|
||||
$di['db'] = $dbMock;
|
||||
$di['em'] = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing();
|
||||
|
||||
$clientApi->setDi($di);
|
||||
|
||||
@@ -66,9 +58,12 @@ test('ticket get list', function (): void {
|
||||
|
||||
test('ticket get', function (): void {
|
||||
$clientApi = new Box\Mod\Support\Api\Client();
|
||||
$ticket = new Box\Mod\Support\Entity\SupportTicket();
|
||||
\Tests\Helpers\setEntityId($ticket, 1);
|
||||
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('findOneByClient')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn($ticket);
|
||||
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -147,14 +142,15 @@ test('ticket reply', function (): void {
|
||||
$serviceMock->shouldReceive('ticketReply')->atLeast()->once()
|
||||
->andReturn(1);
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('findOne')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
$ticket = new Box\Mod\Support\Entity\SupportTicket();
|
||||
Tests\Helpers\setEntityId($ticket, 1);
|
||||
$ticketRepoMock = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
|
||||
$ticketRepoMock->shouldReceive('findOneByClient')->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
$serviceMock->shouldReceive('getSupportTicketRepository')->atLeast()->once()
|
||||
->andReturn($ticketRepoMock);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
$clientApi->setDi($di);
|
||||
|
||||
$client = new Model_Client();
|
||||
@@ -182,15 +178,15 @@ test('ticket reply can not be reopened exception', function (): void {
|
||||
$serviceMock->shouldReceive('ticketReply')
|
||||
->andReturn(1);
|
||||
|
||||
$dbMock = Mockery::mock('\Box_Database');
|
||||
$dbMock
|
||||
->shouldReceive('findOne')
|
||||
->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
$ticket = new Box\Mod\Support\Entity\SupportTicket();
|
||||
Tests\Helpers\setEntityId($ticket, 1);
|
||||
$ticketRepoMock = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
|
||||
$ticketRepoMock->shouldReceive('findOneByClient')->atLeast()->once()
|
||||
->andReturn($ticket);
|
||||
$serviceMock->shouldReceive('getSupportTicketRepository')->atLeast()->once()
|
||||
->andReturn($ticketRepoMock);
|
||||
|
||||
$di = container();
|
||||
$di['db'] = $dbMock;
|
||||
|
||||
$clientApi->setDi($di);
|
||||
|
||||
$client = new Model_Client();
|
||||
@@ -211,7 +207,7 @@ test('ticket close', function (): void {
|
||||
$clientApi = new Box\Mod\Support\Api\Client();
|
||||
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
|
||||
$serviceMock->shouldReceive('findOneByClient')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$serviceMock->shouldReceive('closeTicket')->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ test('ticket get', function (): void {
|
||||
$guestApi = new Box\Mod\Support\Api\Guest();
|
||||
$serviceMock = guestSupportServiceMock();
|
||||
$serviceMock->shouldReceive('findOneByHash')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()
|
||||
->andReturn([]);
|
||||
|
||||
@@ -113,7 +113,7 @@ test('ticket close', function (): void {
|
||||
$guestApi = new Box\Mod\Support\Api\Guest();
|
||||
$serviceMock = guestSupportServiceMock();
|
||||
$serviceMock->shouldReceive('findOneByHash')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$serviceMock->shouldReceive('closeTicket')->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
@@ -135,7 +135,7 @@ test('ticket reply', function (): void {
|
||||
$guestApi = new Box\Mod\Support\Api\Guest();
|
||||
$serviceMock = guestSupportServiceMock();
|
||||
$serviceMock->shouldReceive('findOneByHash')->atLeast()->once()
|
||||
->andReturn(new Model_SupportTicket());
|
||||
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
|
||||
$serviceMock->shouldReceive('ticketReply')->atLeast()->once()
|
||||
->andReturn(1);
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -168,20 +168,32 @@ function container(): Container
|
||||
$emailTemplateRepository->shouldReceive('findOneByActionCode')->byDefault()->andReturn(null);
|
||||
$emailTemplateRepository->shouldReceive('getSearchQueryBuilder')->byDefault()->andReturn($templateQueryBuilder);
|
||||
|
||||
$activityClientEmailRepository = \Mockery::mock(\Box\Mod\Email\Repository\ActivityClientEmailRepository::class)->shouldIgnoreMissing();
|
||||
$queuedEmailRepository = \Mockery::mock(\Box\Mod\Email\Repository\QueuedEmailRepository::class)->shouldIgnoreMissing();
|
||||
|
||||
$kbArticleRepository = \Mockery::mock(\Box\Mod\Support\Repository\KbArticleRepository::class)->shouldIgnoreMissing();
|
||||
$kbArticleCategoryRepository = \Mockery::mock(\Box\Mod\Support\Repository\KbArticleCategoryRepository::class)->shouldIgnoreMissing();
|
||||
$cannedResponseRepository = \Mockery::mock(\Box\Mod\Support\Repository\CannedResponseRepository::class)->shouldIgnoreMissing();
|
||||
$cannedResponseCategoryRepository = \Mockery::mock(\Box\Mod\Support\Repository\CannedResponseCategoryRepository::class)->shouldIgnoreMissing();
|
||||
$helpdeskRepository = \Mockery::mock(\Box\Mod\Support\Repository\HelpdeskRepository::class)->shouldIgnoreMissing();
|
||||
$supportTicketRepository = \Mockery::mock(\Box\Mod\Support\Repository\SupportTicketRepository::class)->shouldIgnoreMissing();
|
||||
$supportTicketMessageRepository = \Mockery::mock(\Box\Mod\Support\Repository\SupportTicketMessageRepository::class)->shouldIgnoreMissing();
|
||||
$supportTicketNoteRepository = \Mockery::mock(\Box\Mod\Support\Repository\SupportTicketNoteRepository::class)->shouldIgnoreMissing();
|
||||
|
||||
$em = \Mockery::mock(\Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing();
|
||||
$em->shouldReceive('getRepository')->byDefault()->andReturnUsing(static fn (string $class): object => match ($class) {
|
||||
\Box\Mod\Email\Entity\EmailTemplate::class => $emailTemplateRepository,
|
||||
\Box\Mod\Email\Entity\ActivityClientEmail::class => $activityClientEmailRepository,
|
||||
\Box\Mod\Email\Entity\QueuedEmail::class => $queuedEmailRepository,
|
||||
\Box\Mod\Support\Entity\KbArticle::class => $kbArticleRepository,
|
||||
\Box\Mod\Support\Entity\KbArticleCategory::class => $kbArticleCategoryRepository,
|
||||
\Box\Mod\Support\Entity\CannedResponse::class => $cannedResponseRepository,
|
||||
\Box\Mod\Support\Entity\CannedResponseCategory::class => $cannedResponseCategoryRepository,
|
||||
\Box\Mod\Support\Entity\Helpdesk::class => $helpdeskRepository,
|
||||
\Box\Mod\Support\Entity\SupportTicket::class => $supportTicketRepository,
|
||||
\Box\Mod\Support\Entity\SupportTicketMessage::class => $supportTicketMessageRepository,
|
||||
\Box\Mod\Support\Entity\SupportTicketNote::class => $supportTicketNoteRepository,
|
||||
\Box\Mod\Extension\Entity\Extension::class => \Mockery::mock(\Box\Mod\Extension\Repository\ExtensionRepository::class)->shouldIgnoreMissing(),
|
||||
default => $extensionMetaRepository,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Helpers;
|
||||
|
||||
/**
|
||||
* Force the value of an entity's `id` property via reflection.
|
||||
*
|
||||
* Doctrine entities have private `id` properties; tests need to set them
|
||||
* directly to simulate a persisted row without going through the database.
|
||||
*/
|
||||
function setEntityId(object $entity, int $id): void
|
||||
{
|
||||
$reflection = new \ReflectionProperty($entity, 'id');
|
||||
$reflection->setValue($entity, $id);
|
||||
}
|
||||
Reference in new issue
Block a user