<?php
declare(strict_types=1);
namespace App\Entity;
use App\Contracts\Activity\ActivityBoostStatus;
use App\Contracts\Activity\Status;
use App\Contracts\Platform\Platform;
use App\Entity\Lite\CreatorLite;
use App\Repository\ActivityRepository;
use App\Services\DraftCampaign\Contract\DraftVideoStatus;
use DateTime;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\HasLifecycleCallbacks;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Index;
use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\ORM\Mapping\OneToOne;
use Doctrine\ORM\Mapping\PrePersist;
use Doctrine\ORM\Mapping\PreUpdate;
use Doctrine\ORM\Mapping\UniqueConstraint;
use Symfony\Component\String\AbstractString;
use function Symfony\Component\String\s;
#[Entity(ActivityRepository::class)]
#[Index(fields: ['status', 'updatedAt'])]
#[UniqueConstraint('unique_activity', ['creator_id', 'campaign_id'])]
#[HasLifecycleCallbacks]
class Activity
{
/** @var int */
private const READ_MORE_LENGTH = 35;
/** @var int */
private const DEFAULT_AMOUNT = 0;
#[Id]
#[GeneratedValue]
#[Column('id', 'bigint', options: ['unsigned' => true])]
private ?int $id = null;
#[ManyToOne(targetEntity: Creator::class, inversedBy: 'activities')]
#[JoinColumn(onDelete: 'CASCADE')]
private Creator|CreatorLite $creator;
#[Column(
type: 'bigint',
options: [
'unsigned' => true,
'comment' => 'Field to specify the user responsible for processing the activity',
],
)]
private ?int $curatorUserId = null;
#[Column(type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $curatedAt = null;
#[ManyToOne(targetEntity: Campaign::class, inversedBy: 'activities')]
private Campaign $campaign;
#[OneToOne(targetEntity: Video::class)]
private ?Video $video = null;
#[OneToOne(targetEntity: Complaint::class, cascade: ['persist'])]
#[JoinColumn(onDelete: 'SET NULL')]
private ?Complaint $complaint = null;
#[Column(name: 'boost_status', type: 'smallint', nullable: false, options: [
'comment' => 'Status related to the boost code request from creator',
])]
private int $boostStatus = ActivityBoostStatus::NONE;
#[Column('boost_requested_by_admin', 'boolean', nullable: false, options: ['default' => false])]
private bool $boostRequestedByAdmin = false;
#[OneToMany(mappedBy: 'activity', targetEntity: VideoPending::class)]
private Collection $pendingVideos;
#[OneToMany(mappedBy: 'activity', targetEntity: DraftVideo::class)]
private Collection $draftVideos;
#[Column(name: 'status', type: 'smallint')]
private int $status = Status::INBOX;
#[Column('amount', 'float', options: ['unsigned' => true, 'default' => self::DEFAULT_AMOUNT])]
private float $amount = self::DEFAULT_AMOUNT;
#[Column(type: 'float', precision: 10, scale: 2, nullable: false, options: ['unsigned' => true])]
private float $cpm;
#[Column('paid', 'float', options: ['unsigned' => true, 'default' => self::DEFAULT_AMOUNT])]
private float $paid = self::DEFAULT_AMOUNT;
#[Column('personal_bonus', 'float', options: ['unsigned' => true, 'default' => self::DEFAULT_AMOUNT])]
private float $personalBonus = self::DEFAULT_AMOUNT;
#[Column('followers', 'bigint', nullable: true, options: ['unsigned' => true])]
private ?int $followers = null;
#[Column('averageViews', 'bigint', nullable: true, options: ['unsigned' => true])]
private ?int $averageViews = null;
#[Column('vip_multiplier', 'float', nullable: true, options: ['unsigned' => true, 'default' => null])]
private ?float $vipMultiplier = null;
#[Column(type: 'text')]
private string $reason = '';
#[Column(type: 'text', nullable: true)]
private ?string $disputeReason = '';
#[Column(
name: 'video_deadline_at',
type: 'datetime_immutable',
nullable: true,
options: [
'comment' => 'End date for adding a video to the activity',
],
)]
private ?DateTimeImmutable $videoDeadlineAt = null;
#[Column(name: 'created_at', type: 'datetime')]
private ?DateTime $createdAt = null;
#[Column(name: 'updated_at', type: 'datetime', nullable: true)]
private ?DateTime $updatedAt = null;
#[Column('payed_at', 'datetime', nullable: true)]
private ?DateTime $payedAt = null;
public function __construct()
{
$this->pendingVideos = new ArrayCollection();
$this->draftVideos = new ArrayCollection();
}
#[PrePersist]
public function prePersist(): void
{
$this->createdAt = new DateTime();
}
#[PreUpdate]
public function preUpdate(): void
{
$this->updatedAt = new DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getCreator(): Creator
{
return $this->creator;
}
public function setCreator(Creator $creator): self
{
$this->creator = $creator;
return $this;
}
public function getCpm(): float
{
return $this->cpm;
}
public function setCpm(float $cpm): static
{
$this->cpm = $cpm;
return $this;
}
public function getCuratorUserId(): ?int
{
return $this->curatorUserId;
}
public function setCuratorUserId(?int $curatorUserId): self
{
$this->curatorUserId = $curatorUserId;
return $this;
}
public function getCuratedAt(): ?DateTimeImmutable
{
return $this->curatedAt;
}
public function setCuratedAt(?DateTimeImmutable $curatedAt): void
{
$this->curatedAt = $curatedAt;
}
public function getCampaign(): Campaign
{
return $this->campaign;
}
public function resolvePlatform(): Platform
{
$platform = $this->campaign->getPlatform() ?? $this->creator->getPlatform();
if ($platform === null) {
throw new \RuntimeException(
sprintf('Activity #%s has no platform on campaign or creator.', $this->id ?? 'unknown'),
);
}
return $platform;
}
public function setCampaign(Campaign $campaign): self
{
$this->campaign = $campaign;
return $this;
}
public function setStatus(int $status): self
{
$this->status = $status;
return $this;
}
public function getStatus(): int
{
return $this->status;
}
public function isHideAudio(): bool
{
return $this->getCampaign()->isHideAudio()
&& ($this->isReviewed() || $this->isInbox());
}
public function isInbox(): bool
{
return Status::INBOX === $this->status;
}
public function isAccepted(): bool
{
return in_array($this->status, [
Status::ACCEPTED,
Status::SPONSOR_REVIEW,
Status::VIDEO_PENDING,
], true);
}
public function isActive(): bool
{
return Status::ACTIVE === $this->status;
}
public function isReviewed(): bool
{
return Status::SPONSOR_REVIEW === $this->status;
}
public function isVideoPending(): bool
{
return Status::VIDEO_PENDING === $this->status;
}
public function isVideoReview(): bool
{
return Status::VIDEO_REVIEW === $this->status;
}
public function isDraftVideoReview(): bool
{
return Status::DRAFT_VIDEO_REVIEW === $this->status;
}
public function isCompleted(): bool
{
return Status::COMPLETED === $this->status;
}
public function isDeclined(): bool
{
return in_array($this->status, [
Status::DECLINE,
Status::SPONSOR_DECLINE,
], true);
}
public function isSponsorDeclined(): bool
{
return Status::SPONSOR_DECLINE === $this->status;
}
public function accept(int $videoExecutionLimitInDays = 0, ?float $vipMultiplier = null, int $newStatus = Status::ACCEPTED): self
{
$this->followers = $this->getCreator()->getFollowers();
$this->averageViews = $this->getCreator()->getAverageViews();
$this->status = $newStatus;
$this->vipMultiplier = $vipMultiplier;
if ($videoExecutionLimitInDays > 0) {
$this->videoDeadlineAt = (new DateTimeImmutable())->modify(sprintf('+%d days', $videoExecutionLimitInDays));
}
return $this;
}
public function decline(bool $bySponsor = false): self
{
$this->status = $bySponsor ? Status::SPONSOR_DECLINE : Status::DECLINE;
return $this;
}
public function activate(): self
{
$this->status = Status::ACTIVE;
return $this;
}
public function videoPending(): self
{
$this->status = Status::VIDEO_PENDING;
return $this;
}
public function review(): self
{
$this->status = Status::SPONSOR_REVIEW;
return $this;
}
public function complete(): self
{
$this->status = Status::COMPLETED;
return $this;
}
public function setVideo(Video $video): self
{
$this->video = $video;
return $this;
}
public function getVideo(): ?Video
{
return $this->video;
}
public function getComplaint(): ?Complaint
{
return $this->complaint;
}
public function setComplaint(?Complaint $complaint): self
{
$this->complaint = $complaint;
return $this;
}
public function isBoostCodeNotRequested(): bool
{
return ActivityBoostStatus::NONE === $this->boostStatus;
}
public function isBoostCodeRequested(): bool
{
return ActivityBoostStatus::REQUESTED === $this->boostStatus;
}
public function isBoostCodeReceived(): bool
{
return ActivityBoostStatus::RECEIVED === $this->boostStatus;
}
public function isBoostCodeRequestedByAdmin(): bool
{
return $this->boostRequestedByAdmin;
}
public function setBoostRequestedByAdmin(bool $boostRequestedByAdmin): self
{
$this->boostRequestedByAdmin = $boostRequestedByAdmin;
return $this;
}
public function getBoostStatus(): int
{
return $this->boostStatus;
}
public function setBoostStatus(int $boostStatus): self
{
$this->boostStatus = $boostStatus;
return $this;
}
public function getVideoDeadlineAt(): ?DateTimeImmutable
{
return $this->videoDeadlineAt;
}
public function setVideoDeadlineAt(?DateTimeImmutable $videoDeadlineAt): void
{
$this->videoDeadlineAt = $videoDeadlineAt;
}
public function getCreatedAt(): ?DateTime
{
return $this->createdAt;
}
public function getUpdatedAt(): ?DateTime
{
return $this->updatedAt;
}
public function getInstructions(): AbstractString
{
return s($this->getCampaign()->getInstructions());
}
public function isReadMore(): bool
{
return $this->getCampaign()->isPredefinedInstructions()
&& $this->getInstructions()->length() > self::READ_MORE_LENGTH;
}
public function getShortInstructions(): string
{
return $this->getInstructions()->truncate(self::READ_MORE_LENGTH, cut: false)->toString();
}
public function setAmount(float $amount): self
{
$this->amount = $amount;
return $this;
}
public function getAmount(): float
{
return $this->amount;
}
public function setPaid(float $paid): self
{
$this->paid = $paid;
return $this;
}
public function getPaid(): float
{
return $this->paid;
}
public function getPayedAt(): ?DateTime
{
return $this->payedAt;
}
public function confirmPayout(): self
{
$this->payedAt = new DateTime();
return $this;
}
public function isNeedPayment(): bool
{
return $this->isCompleted() && $this->getPayedAt() === null;
}
public function videoPaid(): bool
{
return Status::COMPLETED === $this->getStatus() && $this->getPayedAt() !== null;
}
public function setFollowers(int $followers): self
{
$this->followers = $followers;
return $this;
}
public function getFollowers(): int
{
return $this->followers ?? $this->getCreator()->getFollowers();
}
public function getRawFollowers(): ?int
{
return $this->followers;
}
public function setAverageViews(int $averageViews): self
{
$this->averageViews = $averageViews;
return $this;
}
public function getAverageViews(): int
{
return $this->averageViews ?? $this->getCreator()->getAverageViews();
}
public function getVipMultiplier(): ?float
{
return $this->vipMultiplier;
}
public function setVipMultiplier(?float $vipMultiplier): self
{
$this->vipMultiplier = $vipMultiplier;
return $this;
}
public function getRawAverageViews(): ?int
{
return $this->averageViews;
}
public function getPersonalBonus(): float
{
return $this->personalBonus;
}
public function setPersonalBonus(float $personalBonus): self
{
$this->personalBonus = $personalBonus;
return $this;
}
public function isSetPersonalBonus(): bool
{
return $this->getPersonalBonus() > 0;
}
public function getReason(): string
{
return $this->reason;
}
public function setReason(string $reason): self
{
$this->reason = $reason;
return $this;
}
public function getDisputeReason(): ?string
{
return $this->disputeReason;
}
public function setDisputeReason(?string $disputeReason): self
{
$this->disputeReason = $disputeReason;
return $this;
}
public function getPendingVideos(): Collection
{
return $this->pendingVideos;
}
public function getDraftVideos(): Collection
{
return $this->draftVideos;
}
public function getPendingDraftVideo(): ?DraftVideo
{
$video = $this->getDraftVideos()->filter(function ($video) {
return $video->getStatus() == DraftVideoStatus::PENDING;
})->first();
if ($video === false) {
return null;
}
return $video;
}
public function getApprovedDraftVideo(): ?DraftVideo
{
$video = $this->getDraftVideos()->filter(function ($video) {
return $video->getStatus() == DraftVideoStatus::APPROVED;
})->first();
if ($video === false) {
return null;
}
return $video;
}
public function getActualDraftVideo(): ?DraftVideo
{
if ($this->isDraftVideoReview()) {
return $this->getPendingDraftVideo();
}
return $this->getApprovedDraftVideo();
}
public function isDraftVideoRequired(): bool
{
return
$this->getStatus() == Status::ACCEPTED &&
$this->getActualDraftVideo() === null &&
$this->getCampaign()->isDraftVideo();
}
public function getActualModifyDraftVideo(): ?DraftVideo
{
if ($this->getActualDraftVideo() !== null) {
return null;
}
return $this->getLastModifyDraftVideo();
}
public function getLastModifyDraftVideo(): ?DraftVideo
{
if ($this->getActualDraftVideo() !== null) {
return null;
}
$declinedVideos = $this->getDraftVideos()->filter(function ($video) {
return $video->getStatus() === DraftVideoStatus::MODIFY;
});
if ($declinedVideos->isEmpty()) {
return null;
}
$sortedVideos = $declinedVideos->matching(
Criteria::create()->orderBy(['id' => Criteria::DESC])
);
return $sortedVideos->first();
}
/**
* Average-view rate: a creator-account quality proxy (average views / followers).
* Historically — and misleadingly — surfaced as "engagement rate" on creator-screening
* surfaces (creator review, targeting/eligibility, analytics). This is NOT post
* engagement and must not be confused with {@see getEngagementRate()}.
* See docs/EngagementRateMetric.md.
*/
public function getAverageViewRate(): float
{
if ($this->followers === 0) {
return 0;
}
return $this->averageViews / $this->followers;
}
/**
* Post engagement rate for this activity's video: (likes + comments + shares) / views.
* This is the campaign-performance engagement metric shown on results dashboards.
* Returns 0.0 when there is no video or the video has zero views.
* See docs/EngagementRateMetric.md.
*/
public function getEngagementRate(): float
{
$video = $this->getVideo();
if ($video === null || $video->getViews() === 0) {
return 0.0;
}
return ($video->getLikes() + $video->getComments() + $video->getShares()) / $video->getViews();
}
}