['campaign:read'], 'swagger_definition_name' => 'Read', 'api_included' => ['category', 'events', 'lists', 'forms', 'fields', 'actions'], ], denormalizationContext: [ 'groups' => ['campaign:write'], 'swagger_definition_name' => 'Write', ] )] class Campaign extends FormEntity implements PublishStatusIconAttributesInterface, OptimisticLockInterface, UuidInterface { use UuidTrait; use OptimisticLockTrait; use ProjectTrait; public const TABLE_NAME = 'campaigns'; public const ENTITY_NAME = 'campaign'; /** * @var int */ #[Groups(['campaign:read', 'campaign:write'])] private $id; /** * @var string|null */ #[Groups(['campaign:read', 'campaign:write'])] private $name; /** * @var string|null */ #[Groups(['campaign:read', 'campaign:write'])] private $description; /** * @var \DateTimeInterface|null */ #[Groups(['campaign:read', 'campaign:write'])] private $publishUp; /** * @var \DateTimeInterface|null */ #[Groups(['campaign:read', 'campaign:write'])] private $publishDown; #[Groups(['campaign:read', 'campaign:write'])] public ?\DateTimeInterface $deleted = null; /** * @var Category|null **/ #[Groups(['campaign:read', 'campaign:write'])] private $category; /** * @var Collection|ArrayCollection */ #[Groups(['campaign:read', 'campaign:write'])] private $events; /** * @var ArrayCollection */ #[Groups(['campaign:read', 'campaign:write'])] private Collection $leads; /** * @var Collection */ #[Groups(['campaign:read', 'campaign:write'])] private Collection $lists; /** * @var Collection */ #[Groups(['campaign:read', 'campaign:write'])] private Collection $forms; #[Groups(['campaign:read', 'campaign:write'])] private array $canvasSettings = []; #[Groups(['campaign:read', 'campaign:write'])] private bool $allowRestart = false; public function __construct() { $this->events = new ArrayCollection(); $this->leads = new ArrayCollection(); $this->lists = new ArrayCollection(); $this->forms = new ArrayCollection(); $this->initializeProjects(); } public function __clone() { $this->leads = new ArrayCollection(); $this->events = new ArrayCollection(); $this->lists = new ArrayCollection(); $this->forms = new ArrayCollection(); $this->id = null; parent::__clone(); } public static function loadMetadata(ORM\ClassMetadata $metadata): void { $builder = new ClassMetadataBuilder($metadata); $builder->setTable(self::TABLE_NAME) ->setCustomRepositoryClass(CampaignRepository::class); $builder->addIdColumns(); $builder->addPublishDates(); $builder->addCategory(); $builder->createOneToMany('events', Event::class) ->setIndexBy('id') ->setOrderBy(['order' => 'ASC']) ->mappedBy('campaign') ->cascadeAll() ->fetchExtraLazy() ->build(); $builder->createOneToMany('leads', Lead::class) ->mappedBy('campaign') ->fetchExtraLazy() ->build(); $builder->createManyToMany('lists', LeadList::class) ->setJoinTable('campaign_leadlist_xref') ->setIndexBy('id') ->addInverseJoinColumn('leadlist_id', 'id', false, false, 'CASCADE') ->addJoinColumn('campaign_id', 'id', true, false, 'CASCADE') ->build(); $builder->createManyToMany('forms', Form::class) ->setJoinTable('campaign_form_xref') ->setIndexBy('id') ->addInverseJoinColumn('form_id', 'id', false, false, 'CASCADE') ->addJoinColumn('campaign_id', 'id', true, false, 'CASCADE') ->build(); $builder->createField('canvasSettings', 'array') ->columnName('canvas_settings') ->nullable() ->build(); $builder->addNamedField('allowRestart', 'boolean', 'allow_restart'); $builder->addNullableField('deleted', 'datetime'); self::addVersionField($builder); static::addUuidField($builder); self::addProjectsField($builder, 'campaign_projects_xref', 'campaign_id'); } public static function loadValidatorMetadata(ClassMetadata $metadata): void { $metadata->addPropertyConstraint( 'name', new Assert\NotBlank( [ 'message' => 'mautic.core.name.required', ] ) ); $metadata->addConstraint(new NoOrphanEvents()); } /** * Prepares the metadata for API usage. */ public static function loadApiMetadata(ApiMetadataDriver $metadata): void { $metadata->setGroupPrefix('campaign') ->addListProperties( [ 'id', 'name', 'category', 'description', ] ) ->addProperties( [ 'allowRestart', 'publishUp', 'publishDown', 'events', 'forms', 'lists', // @deprecated, will be renamed to 'segments' in 3.0.0 'canvasSettings', ] ) ->setGroupPrefix('campaignBasic') ->addListProperties( [ 'id', 'name', 'description', 'allowRestart', 'events', 'publishUp', 'publishDown', 'deleted', ] ) ->build(); self::addProjectsInLoadApiMetadata($metadata, 'campaign'); } public function convertToArray(): array { return get_object_vars($this); } /** * @param string $prop * @param mixed $val */ protected function isChanged($prop, $val) { $getter = 'get'.ucfirst($prop); $current = $this->$getter(); if ('category' == $prop) { $currentId = ($current) ? $current->getId() : ''; $newId = ($val) ? $val->getId() : null; if ($currentId != $newId) { $this->changes[$prop] = [$currentId, $newId]; } } elseif ('projects' === $prop) { // Initialize project tracking on first change if (!isset($this->changes['projects']['old'])) { $currentProjects = array_map(fn ($project) => $project->getName(), iterator_to_array($current)); $this->changes['projects'] = [ 'old' => $currentProjects, 'new' => $currentProjects, ]; } // Update the new state based on the operation if ($val instanceof Project) { // Add project if not already in the list $projectName = $val->getName(); if (!in_array($projectName, $this->changes['projects']['new'], true)) { $this->changes['projects']['new'][] = $projectName; } } else { // Remove project from the list $this->changes['projects']['new'] = array_values( array_diff($this->changes['projects']['new'], [$val]) ); } } else { parent::isChanged($prop, $val); } } public function getId(): ?int { return $this->id; } /** * Override to convert projects changes to final format. */ public function getChanges($includePast = false) { $changes = parent::getChanges($includePast); // Convert projects format if it exists and is in the intermediate format if (isset($changes['projects']['old']) && isset($changes['projects']['new'])) { $changes['projects'] = [ implode(', ', $changes['projects']['old']), implode(', ', $changes['projects']['new']), ]; } return $changes; } /** * @param string $description * * @return Campaign */ public function setDescription($description) { $this->isChanged('description', $description); $this->description = $description; return $this; } /** * @return string */ public function getDescription() { return $this->description; } /** * @return Campaign */ public function setName(string $name) { $this->isChanged('name', $name); $this->name = $name; return $this; } /** * @return string */ public function getName() { return $this->name; } /** * Calls $this->addEvent on every item in the collection. * * @return Campaign */ public function addEvents(array $events) { foreach ($events as $id => $event) { $this->addEvent($id, $event); } return $this; } /** * @return Campaign */ public function addEvent($key, Event $event) { if ($changes = $event->getChanges()) { $this->changes['events']['added'][$key] = [$key, $changes]; } $this->events[$key] = $event; return $this; } public function removeEvent(Event $event): void { $this->changes['events']['removed'][$event->getId()] = $event->getName(); $this->events->removeElement($event); } /** * @return ArrayCollection */ public function getEvents() { return $this->events; } public function getRootEvents(): ArrayCollection { $criteria = Criteria::create()->where( Criteria::expr()->andX( Criteria::expr()->isNull('parent'), Criteria::expr()->isNull('deleted') ) ); $events = $this->getEvents()->matching($criteria); return $this->reindexEventsByIdKey($events); } public function getInactionBasedEvents(): ArrayCollection { $criteria = Criteria::create()->where(Criteria::expr()->eq('decisionPath', Event::PATH_INACTION)); $events = $this->getEvents()->matching($criteria); return $this->reindexEventsByIdKey($events); } /** * @param string $type * * @return ArrayCollection */ public function getEventsByType($type): ArrayCollection { $criteria = Criteria::create()->where(Criteria::expr()->eq('eventType', $type)); $events = $this->getEvents()->matching($criteria); return $this->reindexEventsByIdKey($events); } /** * @return ArrayCollection */ public function getEmailSendEvents(): ArrayCollection { $criteria = Criteria::create()->where(Criteria::expr()->eq('type', 'email.send')); $events = $this->getEvents()->matching($criteria); // Doctrine loses the indexBy mapping definition when using matching so we have to manually reset them. // @see https://github.com/doctrine/doctrine2/issues/4693 $keyedArrayCollection = new ArrayCollection(); /** @var Event $event */ foreach ($events as $event) { $keyedArrayCollection->set($event->getId(), $event); } return $keyedArrayCollection; } public function isEmailCampaign(): bool { $criteria = Criteria::create()->where(Criteria::expr()->eq('type', 'email.send'))->setMaxResults(1); $emailEvent = $this->getEvents()->matching($criteria); return !$emailEvent->isEmpty(); } /** * @param ?\DateTime $publishUp * * @return Campaign */ public function setPublishUp($publishUp) { $this->isChanged('publishUp', $publishUp); $this->publishUp = $publishUp; return $this; } /** * @return \DateTimeInterface|null */ public function getPublishUp() { return $this->publishUp; } /** * @param ?\DateTime $publishDown * * @return Campaign */ public function setPublishDown($publishDown) { $this->isChanged('publishDown', $publishDown); $this->publishDown = $publishDown; return $this; } /** * @return \DateTimeInterface */ public function getPublishDown() { return $this->publishDown; } /** * @return mixed */ public function getCategory() { return $this->category; } /** * @param mixed $category */ public function setCategory($category): void { $this->isChanged('category', $category); $this->category = $category; } /** * @return Campaign */ public function addLead($key, Lead $lead) { $action = ($this->leads->contains($lead)) ? 'updated' : 'added'; $leadEntity = $lead->getLead(); $this->changes['leads'][$action][$leadEntity->getId()] = $leadEntity->getPrimaryIdentifier(); $this->leads[$key] = $lead; return $this; } public function removeLead(Lead $lead): void { $leadEntity = $lead->getLead(); $this->changes['leads']['removed'][$leadEntity->getId()] = $leadEntity->getPrimaryIdentifier(); $this->leads->removeElement($lead); } /** * @return Lead[]|Collection */ public function getLeads() { return $this->leads; } /** * @return ArrayCollection */ public function getLists() { return $this->lists; } /** * @return Campaign */ public function addList(LeadList $list) { $this->lists[$list->getId()] = $list; $this->changes['lists']['added'][$list->getId()] = $list->getName(); return $this; } public function removeList(LeadList $list): void { $this->changes['lists']['removed'][$list->getId()] = $list->getName(); $this->lists->removeElement($list); } /** * @return ArrayCollection */ public function getForms() { return $this->forms; } /** * @return Campaign */ public function addForm(Form $form) { $this->forms[$form->getId()] = $form; $this->changes['forms']['added'][$form->getId()] = $form->getName(); return $this; } public function removeForm(Form $form): void { $this->changes['forms']['removed'][$form->getId()] = $form->getName(); $this->forms->removeElement($form); } /** * @return mixed */ public function getCanvasSettings() { return $this->canvasSettings; } public function setCanvasSettings(array $canvasSettings): void { $this->canvasSettings = $canvasSettings; } /** * Check if there are any orphan events that are not connected to any parent node. */ public function hasOrphanEvents(): bool { $canvasSettings = $this->getCanvasSettings() ?? []; if (empty($canvasSettings['nodes'])) { return false; } // Extract event IDs from canvas nodes (excludes 'lists', 'forms' and other non-event nodes) $eventIds = array_filter( array_column($canvasSettings['nodes'], 'id'), fn ($id) => !in_array($id, ['lists', 'forms']) ); if (empty($eventIds)) { return false; } // Extract connected event IDs from connections $connectedEventIds = []; if (!empty($canvasSettings['connections'])) { $connectedEventIds = array_filter(array_column($canvasSettings['connections'], 'targetId')); } return !empty(array_diff($eventIds, $connectedEventIds)); } public function getAllowRestart(): bool { return (bool) $this->allowRestart; } public function allowRestart(): bool { return $this->getAllowRestart(); } /** * @param bool $allowRestart * * @return Campaign */ public function setAllowRestart($allowRestart) { $allowRestart = (bool) $allowRestart; $this->isChanged('allowRestart', $allowRestart); $this->allowRestart = $allowRestart; return $this; } public function setDeleted(?\DateTimeInterface $deleted): void { $this->isChanged('deleted', $deleted); $this->deleted = $deleted; } public function isDeleted(): bool { return !is_null($this->deleted); } /** * Get contact membership. */ public function getContactMembership(Contact $contact): Collection { return $this->leads->matching( Criteria::create() ->where(Criteria::expr()->eq('lead', $contact)) ->orderBy(['dateAdded' => Order::Descending->value]) ); } public function getOnclickMethod(): string { return 'Mautic.confirmationCampaignPublishStatus(mQuery(this));'; } public function getDataAttributes(): array { return [ 'data-toggle' => 'confirmation', 'data-confirm-callback' => 'confirmCallbackCampaignPublishStatus', 'data-cancel-callback' => 'dismissConfirmation', ]; } public function getTranslationKeysDataAttributes(): array { return [ 'data-message' => 'mautic.campaign.form.confirmation.message', 'data-confirm-text' => 'mautic.campaign.form.confirmation.confirm_text', 'data-cancel-text' => 'mautic.campaign.form.confirmation.cancel_text', ]; } /** * Re-index collection by event ID to work around Doctrine's indexBy mapping issue. * * @see https://github.com/doctrine/doctrine2/issues/4693 */ private function reindexEventsByIdKey(Collection $events): ArrayCollection { // Doctrine loses the indexBy mapping definition when using matching so we have to manually reset them. // @see https://github.com/doctrine/doctrine2/issues/4693 $keyedArrayCollection = new ArrayCollection(); /** @var Event $event */ foreach ($events as $event) { $keyedArrayCollection->set($event->getId(), $event); } unset($events); return $keyedArrayCollection; } }