<?php
declare(strict_types=1);
namespace App\Services\Creator;
use App\Dto\Creator\Analytics\UpdateCreatorAnalyticsDto;
use App\Dto\Creator\Country\UpdateCreatorCountryDto;
use App\Contracts\Security\PlatformAuthRequestInterface;
use App\Dto\Creator\MinFlatFee\UpdateCreatorMinFlatFeeDto;
use App\Dto\Creator\Payment\UpdateCreatorStripeConnectedDto;
use App\Entity\AnalyticsCreatorsData;
use App\Contracts\Platform\Platform;
use App\Dto\Creator\Security\TiktokRequestDto;
use App\Entity\Country;
use App\Entity\Creator;
use App\Entity\User;
use App\Entity\PaymentHistory;
use App\Entity\ScrapingCreatorsData;
use App\Entity\ScrapingVideosData;
use App\Entity\VideoPending;
use App\Event\Creator\CreatorTokenRefreshedEvent;
use App\Messenger\Creator\ScrapeCreatorMessage;
use App\Messenger\Creator\UpdateAllStatisticsMessage;
use App\Messenger\Creator\UpdateAvatarMessage;
use App\Repository\CountryRepository;
use App\Repository\CreatorRepository;
use App\Services\Creator\Dto\CreateCreatorDto;
use App\Services\Creator\Dto\CreatorMinFlatFeeDto;
use App\Services\Creator\Dto\CreatorSettingsDto;
use App\Services\Creator\Dto\FindSuitableDto;
use App\Services\Creator\Dto\UpdateBadgesDto;
use App\Services\Creator\Dto\UpdateCreatorCategorySettingsDto;
use App\Services\Creator\Dto\UpdateCreatorCover;
use App\Services\Creator\Dto\UpdateCreatorRostersDto;
use App\Services\Creator\Dto\UpdateCreatorSettingsDto;
use App\Services\Creator\Dto\UpdateCreatorAverageViewsDto;
use App\Services\Creator\Dto\UpdateCreatorCountVideosDto;
use App\Services\Creator\Dto\UpdateCreatorFollowersAndLikesDto;
use App\Services\Creator\Dto\UpdateCreatorStatisticsDto;
use App\Services\Creator\Dto\UpdateVipDto;
use App\Services\Creator\CreatorAccountPeersResolver;
use App\Services\Creator\Event\CreatorCreatedEvent;
use App\Services\Creator\Event\CreatorStatisticUpdatedEvent;
use App\Services\Creator\Exception\AuthException;
use App\Services\Creator\Repository\CreatorCreatingRepositoryInterface;
use App\Services\Creator\Repository\CreatorFindingRepositoryInterface;
use App\Services\Creator\Repository\CreatorUpdatingRepositoryInterface;
use App\Services\Creator\Transaction\DoctrineTransactionFactoryInterface;
use App\Services\Instagram\Exception\InstagramConnectedAccountException;
use App\Services\Platform\PlatformServiceResolver;
use App\Services\Survey\SurveyService;
use App\Services\Survey\SurveyTechFactory;
use App\Services\System\FileService;
use App\Services\System\SettingsServiceInterface;
use App\Services\TikTok\Dto\TikTokOauthTokenDto;
use App\Services\UnitOfWork\UnitOfWorkInterface;
use App\Services\User\ReferralService;
use App\Services\User\UserService;
use DateTime;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NoResultException;
use function Sentry\captureException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
use App\Services\TikTok\TikTokApiServiceInterface;
use Symfony\Component\Uid\Uuid;
use Throwable;
class CreatorService implements CreatorServiceInterface
{
private const COVER_IMAGE_NAME_TEMPLATE = 'creator.%d.cover';
public const BIRTHDAY_FORMAT = 'm/d/Y';
/**
* How long a creator's TikTok submission account stays locked to its union_id before a swap to a
* DIFFERENT TikTok account is permitted again. {@see assertTikTokSwapAllowed()}.
*/
public const TIKTOK_LOCK_DAYS = 90;
public const UPDATE_CREATOR_ALL_STATISTIC_DAILY_CHECK = 1;
public const UPDATE_CREATOR_AVATAR_CHECK_PERIOD_DAYS = 7;
public const ANDROID_STATE = "android";
public const DESKTOP_STATE = "desktop-state";
public const SETTINGS_CONNECT_STATE = 'settings-connect';
public function __construct(
private readonly CreatorCreatingRepositoryInterface $creatingRepository,
private readonly CreatorFindingRepositoryInterface $findingRepository,
private readonly CreatorUpdatingRepositoryInterface $updatingRepository,
private readonly DoctrineTransactionFactoryInterface $transactionFactory,
private readonly SettingsServiceInterface $settingsService,
private readonly FileService $fileService,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly LoginLinkHandlerInterface $loginLinkHandler,
private readonly CountryRepository $countryRepository,
private readonly UnitOfWorkInterface $unitOfWork,
private readonly UserService $userService,
private readonly SurveyService $surveyService,
private readonly MessageBusInterface $bus,
private readonly PlatformServiceResolver $platformServiceResolver,
private readonly ReferralService $referralService,
private readonly CreatorRepository $creatorRepository,
private readonly TikTokApiServiceInterface $tikTokAuthService,
private readonly EntityManagerInterface $entityManager,
private readonly CreatorAccountPeersResolver $creatorAccountPeersResolver,
) {
}
public function get(int $id): Creator
{
return $this->findingRepository->findCreator($id);
}
public function isLinkedCreatorProfile(int $creatorIdA, int $creatorIdB): bool
{
if ($creatorIdA === $creatorIdB) {
return true;
}
try {
$a = $this->findingRepository->findCreator($creatorIdA);
$b = $this->findingRepository->findCreator($creatorIdB);
} catch (NoResultException) {
return false;
}
return $a->getOwningUserId() === $b->getOwningUserId();
}
public function getSettings(int $id): CreatorSettingsDto
{
$creator = $this->get($id);
return new CreatorSettingsDto(
$creator->getFirstName(),
$creator->getLastName(),
$creator->getGender(),
$creator->getBirthday()->format(static::BIRTHDAY_FORMAT),
$creator->getMinimalAmount(),
$creator->getMinimalVipAmount(),
false,
$creator->isEmailNotifyCampaigns(),
$creator->isEmailNotifyNewsletter(),
$creator->getCountry(),
$creator->getRosters(),
$creator->getBio(),
$creator->getFacebook(),
$creator->getYoutube(),
$creator->getInstagram(),
$creator->getTwitter(),
$this->creatorAccountPeersResolver->resolveConsistentPaypalId($creator) ?: null,
);
}
public function updateCreatorCountry(Creator $creator, Country $country): void
{
$updateCreatorCountryDto = new UpdateCreatorCountryDto($creator, $country);
$this->transactionFactory->createTransaction(function () use ($updateCreatorCountryDto) {
$this->updatingRepository->updateCreatorCountry($updateCreatorCountryDto);
})->execute();
}
public function updateAnalytics(Creator $creator, AnalyticsCreatorsData $analyticsCreatorsData): void
{
$updateCreatorAnalyticsDto = new UpdateCreatorAnalyticsDto($creator, $analyticsCreatorsData);
$this->transactionFactory->createTransaction(function () use ($updateCreatorAnalyticsDto) {
$this->updatingRepository->updateCreatorAnalytics($updateCreatorAnalyticsDto);
})->execute();
}
public function updateMinFlatFee(Creator $creator, CreatorMinFlatFeeDto $minFlatFeeCreatorsData): void
{
$updateCreatorMinFlatFeeDto = new UpdateCreatorMinFlatFeeDto($creator->getId(), $minFlatFeeCreatorsData);
$this->transactionFactory->createTransaction(function () use ($updateCreatorMinFlatFeeDto) {
$this->updatingRepository->updateCreatorMinFlatFee($updateCreatorMinFlatFeeDto);
})->execute();
}
public function updateProfileSettings(int $id, CreatorSettingsDto $dto): void
{
$updateSettingsDto = new UpdateCreatorSettingsDto(
$id,
$dto->getFirstName(),
$dto->getLastName(),
$dto->getGender(),
$dto->getBirthday(),
$dto->getMinimalAmount(),
$dto->getMinimalVipAmount(),
$dto->isPhoneNotifyCampaigns(),
$dto->isEmailNotifyCampaigns(),
$dto->isEmailNotifyNewsletter(),
$dto->getCountry()?->getId(),
$dto->getBio(),
$dto->getFacebook(),
$dto->getYoutube(),
$dto->getInstagram(),
$dto->getTwitter(),
$dto->getPaypalId(),
$dto->getMinFlatFee(),
$dto->getIsShowLessThanFlatFee(),
);
// TODO: Some Doctrine issue that prevents saving, not sure how to fix
// $this->transactionFactory->createTransaction(function () use ($updateSettingsDto) {
$this->updatingRepository->updateCreatorAllSettings($updateSettingsDto);
$this->saveCreatorAnswerForTechQuestionFromSettings($updateSettingsDto);
// })->execute();
}
public function saveCreatorAnswerForTechQuestionFromSettings(UpdateCreatorSettingsDto $dto): void
{
$creator = $this->findingRepository->findCreator($dto->getId());
$creator->setMinFlatFee(!is_null($dto->getMinFlatFee()) ? $dto->getMinFlatFee() : $creator->getMinFlatFee());
$map = [
SurveyTechFactory::BIO => $dto->getBio(),
SurveyTechFactory::SOCIAL_FACEBOOK => $dto->getFacebook(),
SurveyTechFactory::SOCIAL_INSTAGRAM => $dto->getInstagram(),
SurveyTechFactory::SOCIAL_YOUTUBE => $dto->getYoutube(),
];
foreach ($map as $key => $value) {
if ($value !== '') {
$this->surveyService->saveCreatorAnswerForTechQuestion($key, $value, $creator);
}
}
}
public function updateAllSettings(int $id, CreatorSettingsDto $dto): void
{
if ($dto->getCover() !== null) {
$this->uploadCover($id, $dto->getCover());
}
$updateSettingsDto = new UpdateCreatorSettingsDto(
$id,
$dto->getFirstName(),
$dto->getLastName(),
$dto->getGender(),
$dto->getBirthday(),
$dto->getMinimalAmount(),
$dto->getMinimalVipAmount(),
$dto->isPhoneNotifyCampaigns(),
$dto->isEmailNotifyCampaigns(),
$dto->isEmailNotifyNewsletter(),
$dto->getCountry()?->getId(),
$dto->getBio(),
$dto->getFacebook(),
$dto->getYoutube(),
$dto->getInstagram(),
$dto->getTwitter(),
$dto->getPaypalId(),
$dto->getMinFlatFee(),
$dto->getIsShowLessThanFlatFee(),
);
$this->transactionFactory->createTransaction(function () use ($updateSettingsDto) {
$this->updatingRepository->updateCreatorAllSettings($updateSettingsDto);
$this->saveCreatorAnswerForTechQuestionFromSettings($updateSettingsDto);
})->execute();
$this->updateRosters($id, $dto->getRosters());
}
public function updateCategorySettings(UpdateCreatorCategorySettingsDto $dto): void
{
$this->updatingRepository->updateCreatorCategorySettings($dto);
}
public function uploadCover(int $id, File $fileInfo): void
{
$dto = new UpdateCreatorCover($id, sprintf(self::COVER_IMAGE_NAME_TEMPLATE, $id));
$this->fileService->update($fileInfo, $dto->getCover());
$this->transactionFactory->createTransaction(function () use ($dto) {
$this->updatingRepository->updateCover($dto);
})->execute();
}
public function deleteCover(int $id): void
{
$dto = new UpdateCreatorCover($id, null);
$this->fileService->delete(sprintf(self::COVER_IMAGE_NAME_TEMPLATE, $id));
$this->transactionFactory->createTransaction(function () use ($dto) {
$this->updatingRepository->updateCover($dto);
})->execute();
}
public function updateRosters(int $id, Collection $rosters): void
{
$updateRostersDto = new UpdateCreatorRostersDto($id, $rosters);
$this->transactionFactory->createTransaction(function () use ($updateRostersDto) {
$this->updatingRepository->updateCreatorRosters($updateRostersDto);
})->execute();
}
public function updateAverageViews(int $id, int $averageViews): void
{
$dto = new UpdateCreatorAverageViewsDto($id, $averageViews);
$this->transactionFactory->createTransaction(function () use ($dto) {
$this->updatingRepository->updateCreatorAverageViews($dto);
})->execute();
}
public function updateFollowersAndLikes(int $id, int $followers, int $likes): void
{
$dto = new UpdateCreatorFollowersAndLikesDto($id, $followers, $likes);
$this->transactionFactory->createTransaction(function () use ($dto) {
$this->updatingRepository->updateCreatorFollowers($dto);
})->execute();
}
public function updateVideos(int $id, int $count): void
{
$dto = new UpdateCreatorCountVideosDto($id, $count);
$this->transactionFactory->createTransaction(function () use ($dto) {
$this->updatingRepository->updateCreatorVideos($dto);
})->execute();
}
public function updateCreatorAllStatistic(Creator $creator): void
{
$platformCreatorService = $this->platformServiceResolver->resolveCreatorService($creator->requirePlatform());
$platformCreatorInfo = $platformCreatorService->getCreatorAllInfo($creator);
$dto = new UpdateCreatorStatisticsDto(
id: $creator->getId(),
followers: $platformCreatorInfo->getFollowers(),
likes: $platformCreatorInfo->getLikes(),
averageViews: $platformCreatorInfo->getAverageViews(),
videos: $platformCreatorInfo->getVideos(),
engagementRate: $this->calculateEngagementRateByAverageViewsAndFollowers(
$platformCreatorInfo->getAverageViews(),
$platformCreatorInfo->getFollowers(),
)
);
$this->updatingRepository->updateCreatorStatistics($dto);
$this->eventDispatcher->dispatch(new CreatorStatisticUpdatedEvent($creator));
}
private function calculateEngagementRateByAverageViewsAndFollowers(int $averageViews, int $followers): float
{
return $followers === 0 ? 0 : $averageViews / $followers;
}
public function updateCreatorAllStatisticIfNeeded(Creator $creator): void
{
if ($creator->needUpdateAllStatistic(self::UPDATE_CREATOR_ALL_STATISTIC_DAILY_CHECK)) {
try {
$this->bus->dispatch(new UpdateAllStatisticsMessage($creator->getId()));
} catch (\Throwable $e) {
captureException($e);
}
}
}
public function updateCreatorAvatarIfNeeded(Creator $creator): void
{
if ($creator->needUpdateAvatar(self::UPDATE_CREATOR_AVATAR_CHECK_PERIOD_DAYS)) {
try {
$this->bus->dispatch(new UpdateAvatarMessage($creator->getId()));
} catch (\Throwable $e) {
captureException($e);
}
}
}
public function getByPlatformAuthToken(PlatformAuthRequestInterface $platformAuthRequest): Creator
{
$isPlatformReconnect = false;
try {
$connectedCreatorIds = array_map('intval', $platformAuthRequest->getConnectedCreatorIds());
$linkingCreatorId = $connectedCreatorIds[0] ?? null;
$platformCreatorService = $this->platformServiceResolver->resolveCreatorService($platformAuthRequest->getPlatform());
$creator = $platformCreatorService->resolveCreatorForAuth($platformAuthRequest);
// One-account-only TikTok: reject a swap to a DIFFERENT union_id while the owner's current
// submission account is still within its 90-day lock. Done BEFORE any row create/relink or
// username update so a locked swap leaves no partial state. Fail-open when the identity or
// owner can't be resolved (a TikTok hiccup must never block login — F13).
if ($platformAuthRequest->getPlatform() === Platform::TIKTOK) {
$this->assertTikTokSwapAllowed($creator, $linkingCreatorId);
}
if ($creator instanceof Creator) {
$isPlatformReconnect = $linkingCreatorId !== null;
// Legacy creators without owning_user must send connectedCreatorIds (first linker claims; return logins omit it once claimed).
if ($linkingCreatorId === null && $creator->getOwningUser() === null) {
throw new AuthException(
sprintf(
'connectedCreatorIds is required to claim this %s account onto your user',
$platformAuthRequest->getPlatform()->label(),
),
422
);
}
if ($linkingCreatorId !== null) {
$this->applyPlatformOwningUserClaim($creator, $linkingCreatorId);
}
if (
$platformAuthRequest->getPlatform() === Platform::YOUTUBE
&& $linkingCreatorId !== null
&& !$this->socialTargetMatchesLinkingProfile($creator, $linkingCreatorId)
) {
throw new AuthException('This YouTube account is already linked to another creator', 409);
}
if (
$platformAuthRequest->getPlatform() === Platform::TIKTOK
&& $linkingCreatorId !== null
&& !$this->socialTargetMatchesLinkingProfile($creator, $linkingCreatorId)
) {
throw new AuthException('This TikTok account is already linked to another creator', 409);
}
if (
$platformAuthRequest->getPlatform() === Platform::INSTAGRAM
&& $linkingCreatorId !== null
&& !$this->socialTargetMatchesLinkingProfile($creator, $linkingCreatorId)
) {
throw new AuthException('This Instagram account is already linked to another creator', 409);
}
$this->updateUsername(
creatorId: $creator->getId(),
username: $platformCreatorService->getCreatorUsername($creator)
);
}
if ($creator instanceof CreateCreatorDto) {
$platform = $platformAuthRequest->getPlatform();
if ($platform === Platform::YOUTUBE && $creator->getYoutubeAuth() === null) {
throw new AuthException('Unable to link YouTube account', 422);
}
if ($platform === Platform::INSTAGRAM && $creator->getInstagramAuth() === null) {
throw new AuthException('Unable to link Instagram account', 422);
}
if ($platform === Platform::TIKTOK && $creator->getTikTokAuth() === null) {
throw new AuthException('Unable to link TikTok account', 422);
}
if ($linkingCreatorId !== null) {
$linkProfile = $this->get($linkingCreatorId);
$linkOwnerId = $linkProfile->getOwningUserId();
if ($platform === Platform::YOUTUBE) {
$youtubeAuthId = $creator->getYoutubeAuth()->getId();
try {
$alreadyLinked = $this->findingRepository->findCreatorByYoutubeAuthId($youtubeAuthId);
} catch (NoResultException) {
$alreadyLinked = null;
}
if ($alreadyLinked !== null) {
throw new AuthException(
$alreadyLinked->getOwningUserId() !== $linkOwnerId
? 'This YouTube account is already linked to another user'
: 'This YouTube account is already linked to another creator',
409,
);
}
} elseif ($platform === Platform::INSTAGRAM) {
$instagramAccountId = $creator->getInstagramAuth()->getId();
try {
$alreadyLinked = $this->findingRepository->findCreatorByInstagramAccountId($instagramAccountId);
} catch (NoResultException) {
$alreadyLinked = null;
}
if ($alreadyLinked !== null) {
throw new AuthException(
$alreadyLinked->getOwningUserId() !== $linkOwnerId
? 'This Instagram account is already linked to another user'
: 'This Instagram account is already linked to another creator',
409,
);
}
} elseif ($platform === Platform::TIKTOK) {
$tikTokOpenId = $creator->getTikTokAuth()->getId();
try {
$alreadyLinked = $this->findingRepository->findCreatorByTikTokOpenId($tikTokOpenId);
} catch (NoResultException) {
$alreadyLinked = null;
}
if ($alreadyLinked !== null) {
throw new AuthException(
$alreadyLinked->getOwningUserId() !== $linkOwnerId
? 'This TikTok account is already linked to another user'
: 'This TikTok account is already linked to another creator',
409,
);
}
}
$creator = $this->createCreator($creator, $linkingCreatorId);
$createdId = $creator->getId();
if ($createdId === null) {
throw new \LogicException('New creator row must have an id after createCreator.');
}
$creator = $this->get($createdId);
$this->applyPlatformOwningUserClaim($creator, $linkingCreatorId);
if ($creator->getTikTokAuth() !== null) {
$this->bus->dispatch(new ScrapeCreatorMessage($creator->getId()), [new DelayStamp(600000),]);
}
$this->copyDataToCreator(
sourceCreatorId: $linkingCreatorId,
targetCreator: $creator,
);
} else {
// Non-claim: first-time OAuth signup — new creator row (new `user` + `creator` inheritance row).
$creator = $this->createCreator($creator);
$createdId = $creator->getId();
if ($createdId === null) {
throw new \LogicException('New creator row must have an id after createCreator.');
}
// createCreator() runs inside a transaction that clears/closes the entity manager; the returned
// Creator is detached — re-load so downstream logic operates on a managed entity.
$creator = $this->get($createdId);
if ($creator->getTikTokAuth() !== null) {
$this->bus->dispatch(new ScrapeCreatorMessage($creator->getId()), [new DelayStamp(600000),]);
}
if ($connectedCreatorIds !== []) {
$this->copyDataToCreator(
sourceCreatorId: $connectedCreatorIds[0],
targetCreator: $creator,
);
}
}
}
} catch (AuthException $e) {
throw $e;
} catch (Throwable $e) {
captureException($e);
$message = $e->getMessage() !== '' ? $e->getMessage() : 'Authentication failed';
$code = $e->getCode();
throw new AuthException(
message: $message,
code: is_int($code) && $code > 0 ? $code : 401,
previous: $e
);
}
$this->userService->checkUserDeletedOrBlocked($creator);
$this->updateCreatorStatistic($creator);
if ($isPlatformReconnect) {
$this->eventDispatcher->dispatch(new CreatorTokenRefreshedEvent(
$creator,
$platformAuthRequest->getPlatform(),
));
}
if (
$linkingCreatorId !== null
&& ($platformAuthRequest->getPlatform() === Platform::TIKTOK
|| $platformAuthRequest->getPlatform() === Platform::INSTAGRAM)
) {
$this->deactivatePreviousPlatformCreators($creator, $platformAuthRequest->getPlatform());
}
// One-account-only TikTok: stamp/refresh the lock on the now-active submission row. No-op when
// re-authing the same union_id, so the 90-day window is NOT extended on every login.
if ($platformAuthRequest->getPlatform() === Platform::TIKTOK) {
$this->applyTikTokIdentityLock($creator);
}
return $creator;
}
public function findSuitable(FindSuitableDto $dto): ArrayCollection
{
$creators = $this->findingRepository->findSuitableCreators($dto);
return new ArrayCollection($creators);
}
public function toggleScheme(int $id): void
{
$this->transactionFactory->createTransaction(function () use ($id) {
$this->updatingRepository->toggleScheme($id);
})->execute();
}
public function ban(int $id): void
{
$this->transactionFactory->createTransaction(function () use ($id) {
$this->updatingRepository->banCreator($id);
})->execute();
}
public function updateBadges(int $id, int ...$badges)
{
$dto = new UpdateBadgesDto($id, $badges);
$this->transactionFactory->createTransaction(function () use ($dto) {
$this->updatingRepository->updateAllBadges($dto);
})->execute();
}
public function verify(int $id): void
{
$creator = $this->get($id);
if ($creator->isVerified()) {
return;
}
$this->transactionFactory->createTransaction(function () use ($creator) {
$creator->setVerifiedAt(new DateTime());
})->execute();
}
public function vip(int $id): void
{
$creator = $this->get($id);
if ($creator->isVerified()) {
return;
}
$this->transactionFactory->createTransaction(function () use ($creator) {
$creator->setVerifiedAt(new DateTime());
})->execute();
}
public function unverify(int $id): void
{
$creator = $this->get($id);
$this->transactionFactory->createTransaction(function () use ($creator) {
$creator->setVerifiedAt(null);
})->execute();
}
public function skip(int $id): void
{
$creator = $this->get($id);
if ($creator->isVerified()) {
return;
}
$this->transactionFactory->createTransaction(function () use ($creator) {
$creator->setSkippedAt(new DateTime());
})->execute();
}
public function updateVip(int $id, bool $vip)
{
$dto = new UpdateVipDto($id, $vip);
$this->transactionFactory->createTransaction(function () use ($dto) {
$this->updatingRepository->updateVip($dto);
})->execute();
}
public function updateLastUpdates(int $id, string $keyName): void
{
$this->transactionFactory->createTransaction(function () use ($id, $keyName) {
$this->updatingRepository->updateLastUpdates($id, $keyName);
})->execute();
}
protected function updateUsername(int $creatorId, string $username): void
{
$creator = $this->findingRepository->findCreator($creatorId);
$creator->setUsername($username);
$this->unitOfWork->saveChanges([$creator]);
}
protected function createCreator(CreateCreatorDto $createCreatorDto, ?int $linkingCreatorProfileId = null): Creator
{
return $this->transactionFactory->createTransaction(function () use ($createCreatorDto, $linkingCreatorProfileId) {
$owningUserId = null !== $linkingCreatorProfileId
? $this->findingRepository->findCreator($linkingCreatorProfileId)->getOwningUserId()
: null;
return $this->creatingRepository->createCreator($createCreatorDto, $owningUserId);
})->afterCommit(function (Creator $creator) {
$this->eventDispatcher->dispatch(new CreatorCreatedEvent($creator));
})->execute();
}
protected function updateCreatorStatistic(Creator $creator): void
{
$settings = $this->settingsService->get();
$platformCreatorService = $this->platformServiceResolver->resolveCreatorService($creator->requirePlatform());
try {
$this->updateFollowersAndLikes(
id: $creator->getId(),
followers: $platformCreatorService->getCreatorFollowers($creator),
likes: $platformCreatorService->getCreatorLikes($creator)
);
} catch (Throwable) {
}
try {
$averageViews = $platformCreatorService->getCreatorAverageViews($creator, true);
if (
$settings->getNeedAverageViewsToRegistration() > 0
&& $averageViews < $settings->getNeedAverageViewsToRegistration()
) {
throw new AuthException('
Thank you for applying. We will review your application and get back to you,
if you’re approved to participate in our platform.
');
}
$this->updateAverageViews($creator->getId(), $averageViews);
} catch (AuthException $exception) {
throw $exception;
} catch (Throwable) {
}
try {
$videosCount = $platformCreatorService->getCreatorVideoCount($creator);
if (
$settings->getNeedVideosToRegistration() > 0
&& $videosCount < $settings->getNeedVideosToRegistration()
) {
throw new AuthException('
Thank you for applying. We will review your application and get back to you,
if you’re approved to participate in our platform.
');
}
$this->updateVideos($creator->getId(), $videosCount);
} catch (AuthException $exception) {
throw $exception;
} catch (Throwable) {
}
}
public function copyDataToCreator(int $sourceCreatorId, Creator $targetCreator): void
{
if ($targetCreator->getFirstName()) {
return;
}
try {
$this->transactionFactory->createTransaction(function () use ($sourceCreatorId, $targetCreator) {
$sourceCreator = $this->get($sourceCreatorId);
$targetCreator = $this->get($targetCreator->getId());
$this->surveyService->copySurveyToCreator(
sourceCreatorId: $sourceCreator->getId(),
targetCreator: $targetCreator
);
$targetCreator->setIsSurveyRequired(false);
$targetCreator->setFirstName($sourceCreator->getFirstName());
$targetCreator->setLastName($sourceCreator->getLastName());
$targetCreator->setGender($sourceCreator->getGender());
$targetCreator->setBirthday($sourceCreator->getBirthday());
$targetCreator->setCountry($sourceCreator->getCountry());
$this->updatingRepository->persistCreator($targetCreator);
$this->updateRosters(id: $targetCreator->getId(), rosters: $sourceCreator->getRosters());
})->execute();
} catch (Throwable $e) {
captureException($e);
}
}
public function removeAllCreatorsData(
Creator $entityCreator,
EntityManagerInterface $entityManager
): void {
$this->clearIterable($entityCreator->getActivities(), $entityManager);
$this->clearIterable($entityCreator->getSurveyAnswers(), $entityManager);
$this->clearIterable($entityCreator->getDevices(), $entityManager);
$this->clearIterable($entityManager
->getRepository(VideoPending::class)
->findBy(['creator' => $entityCreator]), $entityManager);
$this->clearIterable($entityCreator->getVideoCollection(), $entityManager);
$this->clearIterable($entityManager
->getRepository(PaymentHistory::class)
->findBy(['user' => $entityCreator]), $entityManager);
$this->clearIterable($entityManager
->getRepository(ScrapingCreatorsData::class)
->findBy(['creator' => $entityCreator]), $entityManager);
$this->clearIterable($entityManager
->getRepository(ScrapingVideosData::class)
->findBy(['creator' => $entityCreator]), $entityManager);
if (!$entityCreator->isDeleted()) {
$entityCreator->setDeletedAt(true);
} else {
// Attention!
$entityManager->remove($entityCreator);
}
$entityManager->flush();
}
public function authByTiktok(TiktokRequestDto $dto): TikTokOauthTokenDto
{
if (
str_starts_with($dto->getState(), self::ANDROID_STATE)
|| $dto->getState() === self::DESKTOP_STATE
|| $dto->getState() === self::SETTINGS_CONNECT_STATE
) {
return $this->tikTokAuthService->accessToken($dto->getCode(), $dto->getState());
}
return $this->tikTokAuthService->accessToken($dto->getCode());
}
public function setCanChangeCountryToCreators(): array
{
return $this->creatorRepository->setCanChangeCountryToCreators();
}
private function clearIterable(
iterable $collection,
EntityManagerInterface $entityManager
): void {
foreach ($collection as $entity) {
$entityManager->remove($entity);
}
}
/**
* When linking a new TikTok/Instagram account, retire other creator rows on the same platform.
*/
private function deactivatePreviousPlatformCreators(Creator $connectedCreator, Platform $platform): void
{
$connectedCreatorId = $connectedCreator->getId();
if ($connectedCreatorId === null) {
return;
}
$accountCreators = $this->findingRepository->findCreatorsForOwningAccount($connectedCreator->getOwningUserId());
$toPersist = [];
foreach ($accountCreators as $peer) {
$peerId = $peer->getId();
if ($peerId === null || $peerId === $connectedCreatorId) {
continue;
}
$hasPlatformAuth = match ($platform) {
Platform::TIKTOK => $peer->getTikTokAuth() !== null,
Platform::INSTAGRAM => $peer->getInstagramAuth() !== null,
default => false,
};
if ($hasPlatformAuth && $peer->isActive()) {
$peer->setIsActive(false);
// Clear the retired row's TikTok lock so ONLY the active submission row ever holds one.
// Otherwise a later swap-back to this account would see its own stale lock and
// applyTikTokIdentityLock() would no-op, permanently defeating the 90-day window (F2).
if ($platform === Platform::TIKTOK) {
$peer->setLockedTikTokUnionId(null);
$peer->setLockedAt(null);
}
$toPersist[] = $peer;
}
}
if (!$connectedCreator->isActive()) {
$connectedCreator->setIsActive(true);
$toPersist[] = $connectedCreator;
}
if ($toPersist !== []) {
$this->unitOfWork->saveChanges($toPersist);
}
}
/**
* One-account-only TikTok enforcement (read side). Throws AuthException(423) when the resolved
* account's union_id differs from the owner's currently-locked submission account AND that lock
* is still inside the {@see self::TIKTOK_LOCK_DAYS} window.
*
* Fail-open by design: if we cannot read the incoming union_id (a TikTok hiccup) or the owning
* account (first-time signup / unclaimed legacy row), there is nothing to enforce and login
* proceeds. Identity is keyed on the stable union_id, never the mutable @handle.
*/
private function assertTikTokSwapAllowed(Creator|CreateCreatorDto $resolved, ?int $linkingCreatorId): void
{
$incomingUnionId = $resolved->getTikTokAuth()?->getUnionId();
if ($incomingUnionId === null) {
return; // identity unknown — cannot enforce a union-keyed lock
}
$ownerId = $this->resolveLockOwnerId($resolved, $linkingCreatorId);
if ($ownerId === null) {
return; // brand-new account / unclaimed legacy row — no prior submission account to protect
}
$lockedUnionId = null;
$lockedAt = null;
$lockedPeerId = null;
foreach ($this->findingRepository->findCreatorsForOwningAccount($ownerId) as $peer) {
// Only the ACTIVE submission row's lock governs. Retired rows have their lock cleared on
// deactivation; filter defensively so a stale lock can never elect itself.
if (!$peer->isActive()) {
continue;
}
$peerUnionId = $peer->getLockedTikTokUnionId();
$peerLockedAt = $peer->getLockedAt();
if ($peerUnionId === null || $peerLockedAt === null) {
continue;
}
$peerId = $peer->getId() ?? 0;
// Deterministic selection (F1/F3): most-recent lock wins; on an identical timestamp (e.g. a
// bulk seed stamping one NOW()) the highest creator id wins — never DB row order.
if (
$lockedAt === null
|| $peerLockedAt > $lockedAt
|| ($peerLockedAt == $lockedAt && $peerId > $lockedPeerId)
) {
$lockedUnionId = $peerUnionId;
$lockedAt = $peerLockedAt;
$lockedPeerId = $peerId;
}
}
if ($lockedUnionId === null || $lockedAt === null) {
return; // Case 1: nothing locked yet
}
if ($lockedUnionId->equals($incomingUnionId)) {
return; // Case 2: same account — reconnect / token refresh always allowed
}
$unlockAt = $lockedAt->modify(sprintf('+%d days', self::TIKTOK_LOCK_DAYS));
if ($unlockAt > new DateTimeImmutable()) {
// Case 3: locked to a different account, still inside the window — reject the swap.
throw new AuthException(
sprintf(
'Your TikTok account is locked to your current submission account. '
. 'You can switch to a different TikTok account after %s.',
$unlockAt->format('Y-m-d')
),
423
);
}
// Case 4: window elapsed — swap allowed; applyTikTokIdentityLock() re-locks to the new account.
}
/**
* Resolve the owning-account id whose lock governs this connect: the linking creator's owner when
* linking/reconnecting from an existing session, else the resolved creator's own owner. Null for a
* first-time signup or an unclaimed legacy row (no owner to protect).
*/
private function resolveLockOwnerId(Creator|CreateCreatorDto $resolved, ?int $linkingCreatorId): ?int
{
if ($linkingCreatorId !== null) {
return $this->get($linkingCreatorId)->getOwningUserId();
}
if ($resolved instanceof Creator && $resolved->getOwningUser() !== null) {
return $resolved->getOwningUserId();
}
return null;
}
/**
* One-account-only TikTok enforcement (write side). Stamps the lock on the now-active submission
* creator. No-op when already locked to the same union_id, so re-auth does NOT extend the window.
*/
private function applyTikTokIdentityLock(Creator $creator): void
{
// Only the active submission row may hold a lock (the read side trusts active rows only).
// By this point the connected row has been activated by deactivatePreviousPlatformCreators /
// is a fresh signup; guard defensively so a lock is never stamped on a retired row.
if (!$creator->isActive()) {
return;
}
$unionId = $creator->getTikTokAuth()?->getUnionId();
if ($unionId === null) {
return;
}
$current = $creator->getLockedTikTokUnionId();
if ($current !== null && $current->equals($unionId)) {
return; // already locked to this account — keep the original lockedAt
}
$creator->setLockedTikTokUnionId($unionId);
$creator->setLockedAt(new DateTimeImmutable());
$this->unitOfWork->saveChanges([$creator]);
}
/**
* Bind the OAuth-linked creator row to the same User account as $linkingCreatorId's profile.
*
* Legacy rows with null owning_user are claimed once (first linker wins).
* If owning_user is already set to a different account, callers receive 409 AuthException.
*/
private function applyPlatformOwningUserClaim(Creator $target, int $linkingCreatorId): void
{
$linkProfile = $this->get($linkingCreatorId);
$linkOwnerId = $linkProfile->getOwningUserId();
if ($target->getOwningUser() === null) {
$target->setOwningUser($this->entityManager->getReference(User::class, $linkOwnerId));
$this->unitOfWork->saveChanges([$target]);
return;
}
if ($target->getOwningUserId() !== $linkOwnerId) {
throw new AuthException('This social account is already linked to another user', 409);
}
}
private function socialTargetMatchesLinkingProfile(Creator $target, int $linkingCreatorId): bool
{
if ($target->getId() === $linkingCreatorId) {
return true;
}
$linkProfile = $this->get($linkingCreatorId);
return $target->getOwningUserId() === $linkProfile->getOwningUserId();
}
public function updateStripeConnected(int $userId, bool $stripeConnected): void
{
$creator = $this->findingRepository->findCreator($userId);
$dto = new UpdateCreatorStripeConnectedDto(
$creator,
$stripeConnected,
);
$this->transactionFactory->createTransaction(function () use ($dto) {
return $this->updatingRepository->updateCreatorStripeConnected($dto);
})->execute();
}
}