src/Services/Creator/CreatorService.php line 366

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Services\Creator;
  4. use App\Dto\Creator\Analytics\UpdateCreatorAnalyticsDto;
  5. use App\Dto\Creator\Country\UpdateCreatorCountryDto;
  6. use App\Contracts\Security\PlatformAuthRequestInterface;
  7. use App\Dto\Creator\MinFlatFee\UpdateCreatorMinFlatFeeDto;
  8. use App\Dto\Creator\Payment\UpdateCreatorStripeConnectedDto;
  9. use App\Entity\AnalyticsCreatorsData;
  10. use App\Contracts\Platform\Platform;
  11. use App\Dto\Creator\Security\TiktokRequestDto;
  12. use App\Entity\Country;
  13. use App\Entity\Creator;
  14. use App\Entity\User;
  15. use App\Entity\PaymentHistory;
  16. use App\Entity\ScrapingCreatorsData;
  17. use App\Entity\ScrapingVideosData;
  18. use App\Entity\VideoPending;
  19. use App\Event\Creator\CreatorTokenRefreshedEvent;
  20. use App\Messenger\Creator\ScrapeCreatorMessage;
  21. use App\Messenger\Creator\UpdateAllStatisticsMessage;
  22. use App\Messenger\Creator\UpdateAvatarMessage;
  23. use App\Repository\CountryRepository;
  24. use App\Repository\CreatorRepository;
  25. use App\Services\Creator\Dto\CreateCreatorDto;
  26. use App\Services\Creator\Dto\CreatorMinFlatFeeDto;
  27. use App\Services\Creator\Dto\CreatorSettingsDto;
  28. use App\Services\Creator\Dto\FindSuitableDto;
  29. use App\Services\Creator\Dto\UpdateBadgesDto;
  30. use App\Services\Creator\Dto\UpdateCreatorCategorySettingsDto;
  31. use App\Services\Creator\Dto\UpdateCreatorCover;
  32. use App\Services\Creator\Dto\UpdateCreatorRostersDto;
  33. use App\Services\Creator\Dto\UpdateCreatorSettingsDto;
  34. use App\Services\Creator\Dto\UpdateCreatorAverageViewsDto;
  35. use App\Services\Creator\Dto\UpdateCreatorCountVideosDto;
  36. use App\Services\Creator\Dto\UpdateCreatorFollowersAndLikesDto;
  37. use App\Services\Creator\Dto\UpdateCreatorStatisticsDto;
  38. use App\Services\Creator\Dto\UpdateVipDto;
  39. use App\Services\Creator\CreatorAccountPeersResolver;
  40. use App\Services\Creator\Event\CreatorCreatedEvent;
  41. use App\Services\Creator\Event\CreatorStatisticUpdatedEvent;
  42. use App\Services\Creator\Exception\AuthException;
  43. use App\Services\Creator\Repository\CreatorCreatingRepositoryInterface;
  44. use App\Services\Creator\Repository\CreatorFindingRepositoryInterface;
  45. use App\Services\Creator\Repository\CreatorUpdatingRepositoryInterface;
  46. use App\Services\Creator\Transaction\DoctrineTransactionFactoryInterface;
  47. use App\Services\Instagram\Exception\InstagramConnectedAccountException;
  48. use App\Services\Platform\PlatformServiceResolver;
  49. use App\Services\Survey\SurveyService;
  50. use App\Services\Survey\SurveyTechFactory;
  51. use App\Services\System\FileService;
  52. use App\Services\System\SettingsServiceInterface;
  53. use App\Services\TikTok\Dto\TikTokOauthTokenDto;
  54. use App\Services\UnitOfWork\UnitOfWorkInterface;
  55. use App\Services\User\ReferralService;
  56. use App\Services\User\UserService;
  57. use DateTime;
  58. use DateTimeImmutable;
  59. use Doctrine\Common\Collections\ArrayCollection;
  60. use Doctrine\Common\Collections\Collection;
  61. use Doctrine\ORM\EntityManagerInterface;
  62. use Doctrine\ORM\NoResultException;
  63. use function Sentry\captureException;
  64. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  65. use Symfony\Component\HttpFoundation\File\File;
  66. use Symfony\Component\Messenger\MessageBusInterface;
  67. use Symfony\Component\Messenger\Stamp\DelayStamp;
  68. use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
  69. use App\Services\TikTok\TikTokApiServiceInterface;
  70. use Symfony\Component\Uid\Uuid;
  71. use Throwable;
  72. class CreatorService implements CreatorServiceInterface
  73. {
  74.     private const COVER_IMAGE_NAME_TEMPLATE 'creator.%d.cover';
  75.     public const BIRTHDAY_FORMAT 'm/d/Y';
  76.     /**
  77.      * How long a creator's TikTok submission account stays locked to its union_id before a swap to a
  78.      * DIFFERENT TikTok account is permitted again. {@see assertTikTokSwapAllowed()}.
  79.      */
  80.     public const TIKTOK_LOCK_DAYS 90;
  81.     public const UPDATE_CREATOR_ALL_STATISTIC_DAILY_CHECK 1;
  82.     public const UPDATE_CREATOR_AVATAR_CHECK_PERIOD_DAYS 7;
  83.     public const ANDROID_STATE "android";
  84.     public const DESKTOP_STATE "desktop-state";
  85.     public const SETTINGS_CONNECT_STATE 'settings-connect';
  86.     public function __construct(
  87.         private readonly CreatorCreatingRepositoryInterface $creatingRepository,
  88.         private readonly CreatorFindingRepositoryInterface $findingRepository,
  89.         private readonly CreatorUpdatingRepositoryInterface $updatingRepository,
  90.         private readonly DoctrineTransactionFactoryInterface $transactionFactory,
  91.         private readonly SettingsServiceInterface $settingsService,
  92.         private readonly FileService $fileService,
  93.         private readonly EventDispatcherInterface $eventDispatcher,
  94.         private readonly LoginLinkHandlerInterface $loginLinkHandler,
  95.         private readonly CountryRepository $countryRepository,
  96.         private readonly UnitOfWorkInterface $unitOfWork,
  97.         private readonly UserService $userService,
  98.         private readonly SurveyService $surveyService,
  99.         private readonly MessageBusInterface $bus,
  100.         private readonly PlatformServiceResolver $platformServiceResolver,
  101.         private readonly ReferralService $referralService,
  102.         private readonly CreatorRepository $creatorRepository,
  103.         private readonly TikTokApiServiceInterface $tikTokAuthService,
  104.         private readonly EntityManagerInterface $entityManager,
  105.         private readonly CreatorAccountPeersResolver $creatorAccountPeersResolver,
  106.     ) {
  107.     }
  108.     public function get(int $id): Creator
  109.     {
  110.         return $this->findingRepository->findCreator($id);
  111.     }
  112.     public function isLinkedCreatorProfile(int $creatorIdAint $creatorIdB): bool
  113.     {
  114.         if ($creatorIdA === $creatorIdB) {
  115.             return true;
  116.         }
  117.         try {
  118.             $a $this->findingRepository->findCreator($creatorIdA);
  119.             $b $this->findingRepository->findCreator($creatorIdB);
  120.         } catch (NoResultException) {
  121.             return false;
  122.         }
  123.         return $a->getOwningUserId() === $b->getOwningUserId();
  124.     }
  125.     public function getSettings(int $id): CreatorSettingsDto
  126.     {
  127.         $creator $this->get($id);
  128.         return new CreatorSettingsDto(
  129.             $creator->getFirstName(),
  130.             $creator->getLastName(),
  131.             $creator->getGender(),
  132.             $creator->getBirthday()->format(static::BIRTHDAY_FORMAT),
  133.             $creator->getMinimalAmount(),
  134.             $creator->getMinimalVipAmount(),
  135.             false,
  136.             $creator->isEmailNotifyCampaigns(),
  137.             $creator->isEmailNotifyNewsletter(),
  138.             $creator->getCountry(),
  139.             $creator->getRosters(),
  140.             $creator->getBio(),
  141.             $creator->getFacebook(),
  142.             $creator->getYoutube(),
  143.             $creator->getInstagram(),
  144.             $creator->getTwitter(),
  145.             $this->creatorAccountPeersResolver->resolveConsistentPaypalId($creator) ?: null,
  146.         );
  147.     }
  148.     public function updateCreatorCountry(Creator $creatorCountry $country): void
  149.     {
  150.         $updateCreatorCountryDto = new UpdateCreatorCountryDto($creator$country);
  151.         $this->transactionFactory->createTransaction(function () use ($updateCreatorCountryDto) {
  152.             $this->updatingRepository->updateCreatorCountry($updateCreatorCountryDto);
  153.         })->execute();
  154.     }
  155.     public function updateAnalytics(Creator $creatorAnalyticsCreatorsData $analyticsCreatorsData): void
  156.     {
  157.         $updateCreatorAnalyticsDto = new UpdateCreatorAnalyticsDto($creator$analyticsCreatorsData);
  158.         $this->transactionFactory->createTransaction(function () use ($updateCreatorAnalyticsDto) {
  159.             $this->updatingRepository->updateCreatorAnalytics($updateCreatorAnalyticsDto);
  160.         })->execute();
  161.     }
  162.     public function updateMinFlatFee(Creator $creatorCreatorMinFlatFeeDto $minFlatFeeCreatorsData): void
  163.     {
  164.         $updateCreatorMinFlatFeeDto = new UpdateCreatorMinFlatFeeDto($creator->getId(), $minFlatFeeCreatorsData);
  165.         $this->transactionFactory->createTransaction(function () use ($updateCreatorMinFlatFeeDto) {
  166.             $this->updatingRepository->updateCreatorMinFlatFee($updateCreatorMinFlatFeeDto);
  167.         })->execute();
  168.     }
  169.     public function updateProfileSettings(int $idCreatorSettingsDto $dto): void
  170.     {
  171.         $updateSettingsDto = new UpdateCreatorSettingsDto(
  172.             $id,
  173.             $dto->getFirstName(),
  174.             $dto->getLastName(),
  175.             $dto->getGender(),
  176.             $dto->getBirthday(),
  177.             $dto->getMinimalAmount(),
  178.             $dto->getMinimalVipAmount(),
  179.             $dto->isPhoneNotifyCampaigns(),
  180.             $dto->isEmailNotifyCampaigns(),
  181.             $dto->isEmailNotifyNewsletter(),
  182.             $dto->getCountry()?->getId(),
  183.             $dto->getBio(),
  184.             $dto->getFacebook(),
  185.             $dto->getYoutube(),
  186.             $dto->getInstagram(),
  187.             $dto->getTwitter(),
  188.             $dto->getPaypalId(),
  189.             $dto->getMinFlatFee(),
  190.             $dto->getIsShowLessThanFlatFee(),
  191.         );
  192.         // TODO: Some Doctrine issue that prevents saving, not sure how to fix
  193.         // $this->transactionFactory->createTransaction(function () use ($updateSettingsDto) {
  194.         $this->updatingRepository->updateCreatorAllSettings($updateSettingsDto);
  195.         $this->saveCreatorAnswerForTechQuestionFromSettings($updateSettingsDto);
  196.         // })->execute();
  197.     }
  198.     public function saveCreatorAnswerForTechQuestionFromSettings(UpdateCreatorSettingsDto $dto): void
  199.     {
  200.         $creator $this->findingRepository->findCreator($dto->getId());
  201.         $creator->setMinFlatFee(!is_null($dto->getMinFlatFee()) ? $dto->getMinFlatFee() : $creator->getMinFlatFee());
  202.         $map = [
  203.             SurveyTechFactory::BIO => $dto->getBio(),
  204.             SurveyTechFactory::SOCIAL_FACEBOOK => $dto->getFacebook(),
  205.             SurveyTechFactory::SOCIAL_INSTAGRAM => $dto->getInstagram(),
  206.             SurveyTechFactory::SOCIAL_YOUTUBE => $dto->getYoutube(),
  207.         ];
  208.         foreach ($map as $key => $value) {
  209.             if ($value !== '') {
  210.                 $this->surveyService->saveCreatorAnswerForTechQuestion($key$value$creator);
  211.             }
  212.         }
  213.     }
  214.     public function updateAllSettings(int $idCreatorSettingsDto $dto): void
  215.     {
  216.         if ($dto->getCover() !== null) {
  217.             $this->uploadCover($id$dto->getCover());
  218.         }
  219.         $updateSettingsDto = new UpdateCreatorSettingsDto(
  220.             $id,
  221.             $dto->getFirstName(),
  222.             $dto->getLastName(),
  223.             $dto->getGender(),
  224.             $dto->getBirthday(),
  225.             $dto->getMinimalAmount(),
  226.             $dto->getMinimalVipAmount(),
  227.             $dto->isPhoneNotifyCampaigns(),
  228.             $dto->isEmailNotifyCampaigns(),
  229.             $dto->isEmailNotifyNewsletter(),
  230.             $dto->getCountry()?->getId(),
  231.             $dto->getBio(),
  232.             $dto->getFacebook(),
  233.             $dto->getYoutube(),
  234.             $dto->getInstagram(),
  235.             $dto->getTwitter(),
  236.             $dto->getPaypalId(),
  237.             $dto->getMinFlatFee(),
  238.             $dto->getIsShowLessThanFlatFee(),
  239.         );
  240.         $this->transactionFactory->createTransaction(function () use ($updateSettingsDto) {
  241.             $this->updatingRepository->updateCreatorAllSettings($updateSettingsDto);
  242.             $this->saveCreatorAnswerForTechQuestionFromSettings($updateSettingsDto);
  243.         })->execute();
  244.         $this->updateRosters($id$dto->getRosters());
  245.     }
  246.     public function updateCategorySettings(UpdateCreatorCategorySettingsDto $dto): void
  247.     {
  248.         $this->updatingRepository->updateCreatorCategorySettings($dto);
  249.     }
  250.     public function uploadCover(int $idFile $fileInfo): void
  251.     {
  252.         $dto = new UpdateCreatorCover($idsprintf(self::COVER_IMAGE_NAME_TEMPLATE$id));
  253.         $this->fileService->update($fileInfo$dto->getCover());
  254.         $this->transactionFactory->createTransaction(function () use ($dto) {
  255.             $this->updatingRepository->updateCover($dto);
  256.         })->execute();
  257.     }
  258.     public function deleteCover(int $id): void
  259.     {
  260.         $dto = new UpdateCreatorCover($idnull);
  261.         $this->fileService->delete(sprintf(self::COVER_IMAGE_NAME_TEMPLATE$id));
  262.         $this->transactionFactory->createTransaction(function () use ($dto) {
  263.             $this->updatingRepository->updateCover($dto);
  264.         })->execute();
  265.     }
  266.     public function updateRosters(int $idCollection $rosters): void
  267.     {
  268.         $updateRostersDto = new UpdateCreatorRostersDto($id$rosters);
  269.         $this->transactionFactory->createTransaction(function () use ($updateRostersDto) {
  270.             $this->updatingRepository->updateCreatorRosters($updateRostersDto);
  271.         })->execute();
  272.     }
  273.     public function updateAverageViews(int $idint $averageViews): void
  274.     {
  275.         $dto = new UpdateCreatorAverageViewsDto($id$averageViews);
  276.         $this->transactionFactory->createTransaction(function () use ($dto) {
  277.             $this->updatingRepository->updateCreatorAverageViews($dto);
  278.         })->execute();
  279.     }
  280.     public function updateFollowersAndLikes(int $idint $followersint $likes): void
  281.     {
  282.         $dto = new UpdateCreatorFollowersAndLikesDto($id$followers$likes);
  283.         $this->transactionFactory->createTransaction(function () use ($dto) {
  284.             $this->updatingRepository->updateCreatorFollowers($dto);
  285.         })->execute();
  286.     }
  287.     public function updateVideos(int $idint $count): void
  288.     {
  289.         $dto = new UpdateCreatorCountVideosDto($id$count);
  290.         $this->transactionFactory->createTransaction(function () use ($dto) {
  291.             $this->updatingRepository->updateCreatorVideos($dto);
  292.         })->execute();
  293.     }
  294.     public function updateCreatorAllStatistic(Creator $creator): void
  295.     {
  296.         $platformCreatorService $this->platformServiceResolver->resolveCreatorService($creator->requirePlatform());
  297.         $platformCreatorInfo $platformCreatorService->getCreatorAllInfo($creator);
  298.         $dto = new UpdateCreatorStatisticsDto(
  299.             id$creator->getId(),
  300.             followers$platformCreatorInfo->getFollowers(),
  301.             likes$platformCreatorInfo->getLikes(),
  302.             averageViews$platformCreatorInfo->getAverageViews(),
  303.             videos$platformCreatorInfo->getVideos(),
  304.             engagementRate$this->calculateEngagementRateByAverageViewsAndFollowers(
  305.                 $platformCreatorInfo->getAverageViews(),
  306.                 $platformCreatorInfo->getFollowers(),
  307.             )
  308.         );
  309.         $this->updatingRepository->updateCreatorStatistics($dto);
  310.         $this->eventDispatcher->dispatch(new CreatorStatisticUpdatedEvent($creator));
  311.     }
  312.     private function calculateEngagementRateByAverageViewsAndFollowers(int $averageViewsint $followers): float
  313.     {
  314.         return $followers === $averageViews $followers;
  315.     }
  316.     public function updateCreatorAllStatisticIfNeeded(Creator $creator): void
  317.     {
  318.         if ($creator->needUpdateAllStatistic(self::UPDATE_CREATOR_ALL_STATISTIC_DAILY_CHECK)) {
  319.             try {
  320.                 $this->bus->dispatch(new UpdateAllStatisticsMessage($creator->getId()));
  321.             } catch (\Throwable $e) {
  322.                 captureException($e);
  323.             }
  324.         }
  325.     }
  326.     public function updateCreatorAvatarIfNeeded(Creator $creator): void
  327.     {
  328.         if ($creator->needUpdateAvatar(self::UPDATE_CREATOR_AVATAR_CHECK_PERIOD_DAYS)) {
  329.             try {
  330.                 $this->bus->dispatch(new UpdateAvatarMessage($creator->getId()));
  331.             } catch (\Throwable $e) {
  332.                 captureException($e);
  333.             }
  334.         }
  335.     }
  336.     public function getByPlatformAuthToken(PlatformAuthRequestInterface $platformAuthRequest): Creator
  337.     {
  338.         $isPlatformReconnect false;
  339.         try {
  340.             $connectedCreatorIds array_map('intval'$platformAuthRequest->getConnectedCreatorIds());
  341.             $linkingCreatorId $connectedCreatorIds[0] ?? null;
  342.             $platformCreatorService $this->platformServiceResolver->resolveCreatorService($platformAuthRequest->getPlatform());
  343.             $creator $platformCreatorService->resolveCreatorForAuth($platformAuthRequest);
  344.             // One-account-only TikTok: reject a swap to a DIFFERENT union_id while the owner's current
  345.             // submission account is still within its 90-day lock. Done BEFORE any row create/relink or
  346.             // username update so a locked swap leaves no partial state. Fail-open when the identity or
  347.             // owner can't be resolved (a TikTok hiccup must never block login — F13).
  348.             if ($platformAuthRequest->getPlatform() === Platform::TIKTOK) {
  349.                 $this->assertTikTokSwapAllowed($creator$linkingCreatorId);
  350.             }
  351.             if ($creator instanceof Creator) {
  352.                 $isPlatformReconnect $linkingCreatorId !== null;
  353.                 // Legacy creators without owning_user must send connectedCreatorIds (first linker claims; return logins omit it once claimed).
  354.                 if ($linkingCreatorId === null && $creator->getOwningUser() === null) {
  355.                     throw new AuthException(
  356.                         sprintf(
  357.                             'connectedCreatorIds is required to claim this %s account onto your user',
  358.                             $platformAuthRequest->getPlatform()->label(),
  359.                         ),
  360.                         422
  361.                     );
  362.                 }
  363.                 if ($linkingCreatorId !== null) {
  364.                     $this->applyPlatformOwningUserClaim($creator$linkingCreatorId);
  365.                 }
  366.                 if (
  367.                     $platformAuthRequest->getPlatform() === Platform::YOUTUBE
  368.                     && $linkingCreatorId !== null
  369.                     && !$this->socialTargetMatchesLinkingProfile($creator$linkingCreatorId)
  370.                 ) {
  371.                     throw new AuthException('This YouTube account is already linked to another creator'409);
  372.                 }
  373.                 if (
  374.                     $platformAuthRequest->getPlatform() === Platform::TIKTOK
  375.                     && $linkingCreatorId !== null
  376.                     && !$this->socialTargetMatchesLinkingProfile($creator$linkingCreatorId)
  377.                 ) {
  378.                     throw new AuthException('This TikTok account is already linked to another creator'409);
  379.                 }
  380.                 if (
  381.                     $platformAuthRequest->getPlatform() === Platform::INSTAGRAM
  382.                     && $linkingCreatorId !== null
  383.                     && !$this->socialTargetMatchesLinkingProfile($creator$linkingCreatorId)
  384.                 ) {
  385.                     throw new AuthException('This Instagram account is already linked to another creator'409);
  386.                 }
  387.                 $this->updateUsername(
  388.                     creatorId$creator->getId(),
  389.                     username$platformCreatorService->getCreatorUsername($creator)
  390.                 );
  391.             }
  392.             if ($creator instanceof CreateCreatorDto) {
  393.                 $platform $platformAuthRequest->getPlatform();
  394.                 if ($platform === Platform::YOUTUBE && $creator->getYoutubeAuth() === null) {
  395.                     throw new AuthException('Unable to link YouTube account'422);
  396.                 }
  397.                 if ($platform === Platform::INSTAGRAM && $creator->getInstagramAuth() === null) {
  398.                     throw new AuthException('Unable to link Instagram account'422);
  399.                 }
  400.                 if ($platform === Platform::TIKTOK && $creator->getTikTokAuth() === null) {
  401.                     throw new AuthException('Unable to link TikTok account'422);
  402.                 }
  403.                 if ($linkingCreatorId !== null) {
  404.                     $linkProfile $this->get($linkingCreatorId);
  405.                     $linkOwnerId $linkProfile->getOwningUserId();
  406.                     if ($platform === Platform::YOUTUBE) {
  407.                         $youtubeAuthId $creator->getYoutubeAuth()->getId();
  408.                         try {
  409.                             $alreadyLinked $this->findingRepository->findCreatorByYoutubeAuthId($youtubeAuthId);
  410.                         } catch (NoResultException) {
  411.                             $alreadyLinked null;
  412.                         }
  413.                         if ($alreadyLinked !== null) {
  414.                             throw new AuthException(
  415.                                 $alreadyLinked->getOwningUserId() !== $linkOwnerId
  416.                                     'This YouTube account is already linked to another user'
  417.                                     'This YouTube account is already linked to another creator',
  418.                                 409,
  419.                             );
  420.                         }
  421.                     } elseif ($platform === Platform::INSTAGRAM) {
  422.                         $instagramAccountId $creator->getInstagramAuth()->getId();
  423.                         try {
  424.                             $alreadyLinked $this->findingRepository->findCreatorByInstagramAccountId($instagramAccountId);
  425.                         } catch (NoResultException) {
  426.                             $alreadyLinked null;
  427.                         }
  428.                         if ($alreadyLinked !== null) {
  429.                             throw new AuthException(
  430.                                 $alreadyLinked->getOwningUserId() !== $linkOwnerId
  431.                                     'This Instagram account is already linked to another user'
  432.                                     'This Instagram account is already linked to another creator',
  433.                                 409,
  434.                             );
  435.                         }
  436.                     } elseif ($platform === Platform::TIKTOK) {
  437.                         $tikTokOpenId $creator->getTikTokAuth()->getId();
  438.                         try {
  439.                             $alreadyLinked $this->findingRepository->findCreatorByTikTokOpenId($tikTokOpenId);
  440.                         } catch (NoResultException) {
  441.                             $alreadyLinked null;
  442.                         }
  443.                         if ($alreadyLinked !== null) {
  444.                             throw new AuthException(
  445.                                 $alreadyLinked->getOwningUserId() !== $linkOwnerId
  446.                                     'This TikTok account is already linked to another user'
  447.                                     'This TikTok account is already linked to another creator',
  448.                                 409,
  449.                             );
  450.                         }
  451.                     }
  452.                     $creator $this->createCreator($creator$linkingCreatorId);
  453.                     $createdId $creator->getId();
  454.                     if ($createdId === null) {
  455.                         throw new \LogicException('New creator row must have an id after createCreator.');
  456.                     }
  457.                     $creator $this->get($createdId);
  458.                     $this->applyPlatformOwningUserClaim($creator$linkingCreatorId);
  459.                     if ($creator->getTikTokAuth() !== null) {
  460.                         $this->bus->dispatch(new ScrapeCreatorMessage($creator->getId()), [new DelayStamp(600000),]);
  461.                     }
  462.                     $this->copyDataToCreator(
  463.                         sourceCreatorId$linkingCreatorId,
  464.                         targetCreator$creator,
  465.                     );
  466.                 } else {
  467.                     // Non-claim: first-time OAuth signup — new creator row (new `user` + `creator` inheritance row).
  468.                     $creator $this->createCreator($creator);
  469.                     $createdId $creator->getId();
  470.                     if ($createdId === null) {
  471.                         throw new \LogicException('New creator row must have an id after createCreator.');
  472.                     }
  473.                     // createCreator() runs inside a transaction that clears/closes the entity manager; the returned
  474.                     // Creator is detached — re-load so downstream logic operates on a managed entity.
  475.                     $creator $this->get($createdId);
  476.                     if ($creator->getTikTokAuth() !== null) {
  477.                         $this->bus->dispatch(new ScrapeCreatorMessage($creator->getId()), [new DelayStamp(600000),]);
  478.                     }
  479.                     if ($connectedCreatorIds !== []) {
  480.                         $this->copyDataToCreator(
  481.                             sourceCreatorId$connectedCreatorIds[0],
  482.                             targetCreator$creator,
  483.                         );
  484.                     }
  485.                 }
  486.             }
  487.         } catch (AuthException $e) {
  488.             throw $e;
  489.         } catch (Throwable $e) {
  490.             captureException($e);
  491.             $message $e->getMessage() !== '' $e->getMessage() : 'Authentication failed';
  492.             $code $e->getCode();
  493.             throw new AuthException(
  494.                 message$message,
  495.                 codeis_int($code) && $code $code 401,
  496.                 previous$e
  497.             );
  498.         }
  499.         $this->userService->checkUserDeletedOrBlocked($creator);
  500.         $this->updateCreatorStatistic($creator);
  501.         if ($isPlatformReconnect) {
  502.             $this->eventDispatcher->dispatch(new CreatorTokenRefreshedEvent(
  503.                 $creator,
  504.                 $platformAuthRequest->getPlatform(),
  505.             ));
  506.         }
  507.         if (
  508.             $linkingCreatorId !== null
  509.             && ($platformAuthRequest->getPlatform() === Platform::TIKTOK
  510.                 || $platformAuthRequest->getPlatform() === Platform::INSTAGRAM)
  511.         ) {
  512.             $this->deactivatePreviousPlatformCreators($creator$platformAuthRequest->getPlatform());
  513.         }
  514.         // One-account-only TikTok: stamp/refresh the lock on the now-active submission row. No-op when
  515.         // re-authing the same union_id, so the 90-day window is NOT extended on every login.
  516.         if ($platformAuthRequest->getPlatform() === Platform::TIKTOK) {
  517.             $this->applyTikTokIdentityLock($creator);
  518.         }
  519.         return $creator;
  520.     }
  521.     public function findSuitable(FindSuitableDto $dto): ArrayCollection
  522.     {
  523.         $creators $this->findingRepository->findSuitableCreators($dto);
  524.         return new ArrayCollection($creators);
  525.     }
  526.     public function toggleScheme(int $id): void
  527.     {
  528.         $this->transactionFactory->createTransaction(function () use ($id) {
  529.             $this->updatingRepository->toggleScheme($id);
  530.         })->execute();
  531.     }
  532.     public function ban(int $id): void
  533.     {
  534.         $this->transactionFactory->createTransaction(function () use ($id) {
  535.             $this->updatingRepository->banCreator($id);
  536.         })->execute();
  537.     }
  538.     public function updateBadges(int $idint ...$badges)
  539.     {
  540.         $dto = new UpdateBadgesDto($id$badges);
  541.         $this->transactionFactory->createTransaction(function () use ($dto) {
  542.             $this->updatingRepository->updateAllBadges($dto);
  543.         })->execute();
  544.     }
  545.     public function verify(int $id): void
  546.     {
  547.         $creator $this->get($id);
  548.         if ($creator->isVerified()) {
  549.             return;
  550.         }
  551.         $this->transactionFactory->createTransaction(function () use ($creator) {
  552.             $creator->setVerifiedAt(new DateTime());
  553.         })->execute();
  554.     }
  555.     public function vip(int $id): void
  556.     {
  557.         $creator $this->get($id);
  558.         if ($creator->isVerified()) {
  559.             return;
  560.         }
  561.         $this->transactionFactory->createTransaction(function () use ($creator) {
  562.             $creator->setVerifiedAt(new DateTime());
  563.         })->execute();
  564.     }
  565.     public function unverify(int $id): void
  566.     {
  567.         $creator $this->get($id);
  568.         $this->transactionFactory->createTransaction(function () use ($creator) {
  569.             $creator->setVerifiedAt(null);
  570.         })->execute();
  571.     }
  572.     public function skip(int $id): void
  573.     {
  574.         $creator $this->get($id);
  575.         if ($creator->isVerified()) {
  576.             return;
  577.         }
  578.         $this->transactionFactory->createTransaction(function () use ($creator) {
  579.             $creator->setSkippedAt(new DateTime());
  580.         })->execute();
  581.     }
  582.     public function updateVip(int $idbool $vip)
  583.     {
  584.         $dto = new UpdateVipDto($id$vip);
  585.         $this->transactionFactory->createTransaction(function () use ($dto) {
  586.             $this->updatingRepository->updateVip($dto);
  587.         })->execute();
  588.     }
  589.     public function updateLastUpdates(int $idstring $keyName): void
  590.     {
  591.         $this->transactionFactory->createTransaction(function () use ($id$keyName) {
  592.             $this->updatingRepository->updateLastUpdates($id$keyName);
  593.         })->execute();
  594.     }
  595.     protected function updateUsername(int $creatorIdstring $username): void
  596.     {
  597.         $creator $this->findingRepository->findCreator($creatorId);
  598.         $creator->setUsername($username);
  599.         $this->unitOfWork->saveChanges([$creator]);
  600.     }
  601.     protected function createCreator(CreateCreatorDto $createCreatorDto, ?int $linkingCreatorProfileId null): Creator
  602.     {
  603.         return $this->transactionFactory->createTransaction(function () use ($createCreatorDto$linkingCreatorProfileId) {
  604.             $owningUserId null !== $linkingCreatorProfileId
  605.                 $this->findingRepository->findCreator($linkingCreatorProfileId)->getOwningUserId()
  606.                 : null;
  607.             return $this->creatingRepository->createCreator($createCreatorDto$owningUserId);
  608.         })->afterCommit(function (Creator $creator) {
  609.             $this->eventDispatcher->dispatch(new CreatorCreatedEvent($creator));
  610.         })->execute();
  611.     }
  612.     protected function updateCreatorStatistic(Creator $creator): void
  613.     {
  614.         $settings $this->settingsService->get();
  615.         $platformCreatorService $this->platformServiceResolver->resolveCreatorService($creator->requirePlatform());
  616.         try {
  617.             $this->updateFollowersAndLikes(
  618.                 id$creator->getId(),
  619.                 followers$platformCreatorService->getCreatorFollowers($creator),
  620.                 likes$platformCreatorService->getCreatorLikes($creator)
  621.             );
  622.         } catch (Throwable) {
  623.         }
  624.         try {
  625.             $averageViews $platformCreatorService->getCreatorAverageViews($creatortrue);
  626.             if (
  627.                 $settings->getNeedAverageViewsToRegistration() > 0
  628.                 && $averageViews $settings->getNeedAverageViewsToRegistration()
  629.             ) {
  630.                 throw new AuthException('
  631.                     Thank you for applying. We will review your application and get back to you,
  632.                     if you’re approved to participate in our platform.
  633.                 ');
  634.             }
  635.             $this->updateAverageViews($creator->getId(), $averageViews);
  636.         } catch (AuthException $exception) {
  637.             throw $exception;
  638.         } catch (Throwable) {
  639.         }
  640.         try {
  641.             $videosCount $platformCreatorService->getCreatorVideoCount($creator);
  642.             if (
  643.                 $settings->getNeedVideosToRegistration() > 0
  644.                 && $videosCount $settings->getNeedVideosToRegistration()
  645.             ) {
  646.                 throw new AuthException('
  647.                     Thank you for applying. We will review your application and get back to you,
  648.                     if you’re approved to participate in our platform.
  649.                 ');
  650.             }
  651.             $this->updateVideos($creator->getId(), $videosCount);
  652.         } catch (AuthException $exception) {
  653.             throw $exception;
  654.         } catch (Throwable) {
  655.         }
  656.     }
  657.     public function copyDataToCreator(int $sourceCreatorIdCreator $targetCreator): void
  658.     {
  659.         if ($targetCreator->getFirstName()) {
  660.             return;
  661.         }
  662.         try {
  663.             $this->transactionFactory->createTransaction(function () use ($sourceCreatorId$targetCreator) {
  664.                 $sourceCreator $this->get($sourceCreatorId);
  665.                 $targetCreator $this->get($targetCreator->getId());
  666.                 $this->surveyService->copySurveyToCreator(
  667.                     sourceCreatorId$sourceCreator->getId(),
  668.                     targetCreator$targetCreator
  669.                 );
  670.                 $targetCreator->setIsSurveyRequired(false);
  671.                 $targetCreator->setFirstName($sourceCreator->getFirstName());
  672.                 $targetCreator->setLastName($sourceCreator->getLastName());
  673.                 $targetCreator->setGender($sourceCreator->getGender());
  674.                 $targetCreator->setBirthday($sourceCreator->getBirthday());
  675.                 $targetCreator->setCountry($sourceCreator->getCountry());
  676.                 $this->updatingRepository->persistCreator($targetCreator);
  677.                 $this->updateRosters(id$targetCreator->getId(), rosters$sourceCreator->getRosters());
  678.             })->execute();
  679.         } catch (Throwable $e) {
  680.             captureException($e);
  681.         }
  682.     }
  683.     public function removeAllCreatorsData(
  684.         Creator $entityCreator,
  685.         EntityManagerInterface $entityManager
  686.     ): void {
  687.         $this->clearIterable($entityCreator->getActivities(), $entityManager);
  688.         $this->clearIterable($entityCreator->getSurveyAnswers(), $entityManager);
  689.         $this->clearIterable($entityCreator->getDevices(), $entityManager);
  690.         $this->clearIterable($entityManager
  691.             ->getRepository(VideoPending::class)
  692.             ->findBy(['creator' => $entityCreator]), $entityManager);
  693.         $this->clearIterable($entityCreator->getVideoCollection(), $entityManager);
  694.         $this->clearIterable($entityManager
  695.             ->getRepository(PaymentHistory::class)
  696.             ->findBy(['user' => $entityCreator]), $entityManager);
  697.         $this->clearIterable($entityManager
  698.             ->getRepository(ScrapingCreatorsData::class)
  699.             ->findBy(['creator' => $entityCreator]), $entityManager);
  700.         $this->clearIterable($entityManager
  701.             ->getRepository(ScrapingVideosData::class)
  702.             ->findBy(['creator' => $entityCreator]), $entityManager);
  703.         if (!$entityCreator->isDeleted()) {
  704.             $entityCreator->setDeletedAt(true);
  705.         } else {
  706.             // Attention!
  707.             $entityManager->remove($entityCreator);
  708.         }
  709.         $entityManager->flush();
  710.     }
  711.     public function authByTiktok(TiktokRequestDto $dto): TikTokOauthTokenDto
  712.     {
  713.         if (
  714.             str_starts_with($dto->getState(), self::ANDROID_STATE)
  715.             || $dto->getState() === self::DESKTOP_STATE
  716.             || $dto->getState() === self::SETTINGS_CONNECT_STATE
  717.         ) {
  718.             return $this->tikTokAuthService->accessToken($dto->getCode(), $dto->getState());
  719.         }
  720.         return $this->tikTokAuthService->accessToken($dto->getCode());
  721.     }
  722.     public function setCanChangeCountryToCreators(): array
  723.     {
  724.         return $this->creatorRepository->setCanChangeCountryToCreators();
  725.     }
  726.     private function clearIterable(
  727.         iterable $collection,
  728.         EntityManagerInterface $entityManager
  729.     ): void {
  730.         foreach ($collection as $entity) {
  731.             $entityManager->remove($entity);
  732.         }
  733.     }
  734.     /**
  735.      * When linking a new TikTok/Instagram account, retire other creator rows on the same platform.
  736.      */
  737.     private function deactivatePreviousPlatformCreators(Creator $connectedCreatorPlatform $platform): void
  738.     {
  739.         $connectedCreatorId $connectedCreator->getId();
  740.         if ($connectedCreatorId === null) {
  741.             return;
  742.         }
  743.         $accountCreators $this->findingRepository->findCreatorsForOwningAccount($connectedCreator->getOwningUserId());
  744.         $toPersist = [];
  745.         foreach ($accountCreators as $peer) {
  746.             $peerId $peer->getId();
  747.             if ($peerId === null || $peerId === $connectedCreatorId) {
  748.                 continue;
  749.             }
  750.             $hasPlatformAuth = match ($platform) {
  751.                 Platform::TIKTOK => $peer->getTikTokAuth() !== null,
  752.                 Platform::INSTAGRAM => $peer->getInstagramAuth() !== null,
  753.                 default => false,
  754.             };
  755.             if ($hasPlatformAuth && $peer->isActive()) {
  756.                 $peer->setIsActive(false);
  757.                 // Clear the retired row's TikTok lock so ONLY the active submission row ever holds one.
  758.                 // Otherwise a later swap-back to this account would see its own stale lock and
  759.                 // applyTikTokIdentityLock() would no-op, permanently defeating the 90-day window (F2).
  760.                 if ($platform === Platform::TIKTOK) {
  761.                     $peer->setLockedTikTokUnionId(null);
  762.                     $peer->setLockedAt(null);
  763.                 }
  764.                 $toPersist[] = $peer;
  765.             }
  766.         }
  767.         if (!$connectedCreator->isActive()) {
  768.             $connectedCreator->setIsActive(true);
  769.             $toPersist[] = $connectedCreator;
  770.         }
  771.         if ($toPersist !== []) {
  772.             $this->unitOfWork->saveChanges($toPersist);
  773.         }
  774.     }
  775.     /**
  776.      * One-account-only TikTok enforcement (read side). Throws AuthException(423) when the resolved
  777.      * account's union_id differs from the owner's currently-locked submission account AND that lock
  778.      * is still inside the {@see self::TIKTOK_LOCK_DAYS} window.
  779.      *
  780.      * Fail-open by design: if we cannot read the incoming union_id (a TikTok hiccup) or the owning
  781.      * account (first-time signup / unclaimed legacy row), there is nothing to enforce and login
  782.      * proceeds. Identity is keyed on the stable union_id, never the mutable @handle.
  783.      */
  784.     private function assertTikTokSwapAllowed(Creator|CreateCreatorDto $resolved, ?int $linkingCreatorId): void
  785.     {
  786.         $incomingUnionId $resolved->getTikTokAuth()?->getUnionId();
  787.         if ($incomingUnionId === null) {
  788.             return; // identity unknown — cannot enforce a union-keyed lock
  789.         }
  790.         $ownerId $this->resolveLockOwnerId($resolved$linkingCreatorId);
  791.         if ($ownerId === null) {
  792.             return; // brand-new account / unclaimed legacy row — no prior submission account to protect
  793.         }
  794.         $lockedUnionId null;
  795.         $lockedAt null;
  796.         $lockedPeerId null;
  797.         foreach ($this->findingRepository->findCreatorsForOwningAccount($ownerId) as $peer) {
  798.             // Only the ACTIVE submission row's lock governs. Retired rows have their lock cleared on
  799.             // deactivation; filter defensively so a stale lock can never elect itself.
  800.             if (!$peer->isActive()) {
  801.                 continue;
  802.             }
  803.             $peerUnionId $peer->getLockedTikTokUnionId();
  804.             $peerLockedAt $peer->getLockedAt();
  805.             if ($peerUnionId === null || $peerLockedAt === null) {
  806.                 continue;
  807.             }
  808.             $peerId $peer->getId() ?? 0;
  809.             // Deterministic selection (F1/F3): most-recent lock wins; on an identical timestamp (e.g. a
  810.             // bulk seed stamping one NOW()) the highest creator id wins — never DB row order.
  811.             if (
  812.                 $lockedAt === null
  813.                 || $peerLockedAt $lockedAt
  814.                 || ($peerLockedAt == $lockedAt && $peerId $lockedPeerId)
  815.             ) {
  816.                 $lockedUnionId $peerUnionId;
  817.                 $lockedAt $peerLockedAt;
  818.                 $lockedPeerId $peerId;
  819.             }
  820.         }
  821.         if ($lockedUnionId === null || $lockedAt === null) {
  822.             return; // Case 1: nothing locked yet
  823.         }
  824.         if ($lockedUnionId->equals($incomingUnionId)) {
  825.             return; // Case 2: same account — reconnect / token refresh always allowed
  826.         }
  827.         $unlockAt $lockedAt->modify(sprintf('+%d days'self::TIKTOK_LOCK_DAYS));
  828.         if ($unlockAt > new DateTimeImmutable()) {
  829.             // Case 3: locked to a different account, still inside the window — reject the swap.
  830.             throw new AuthException(
  831.                 sprintf(
  832.                     'Your TikTok account is locked to your current submission account. '
  833.                     'You can switch to a different TikTok account after %s.',
  834.                     $unlockAt->format('Y-m-d')
  835.                 ),
  836.                 423
  837.             );
  838.         }
  839.         // Case 4: window elapsed — swap allowed; applyTikTokIdentityLock() re-locks to the new account.
  840.     }
  841.     /**
  842.      * Resolve the owning-account id whose lock governs this connect: the linking creator's owner when
  843.      * linking/reconnecting from an existing session, else the resolved creator's own owner. Null for a
  844.      * first-time signup or an unclaimed legacy row (no owner to protect).
  845.      */
  846.     private function resolveLockOwnerId(Creator|CreateCreatorDto $resolved, ?int $linkingCreatorId): ?int
  847.     {
  848.         if ($linkingCreatorId !== null) {
  849.             return $this->get($linkingCreatorId)->getOwningUserId();
  850.         }
  851.         if ($resolved instanceof Creator && $resolved->getOwningUser() !== null) {
  852.             return $resolved->getOwningUserId();
  853.         }
  854.         return null;
  855.     }
  856.     /**
  857.      * One-account-only TikTok enforcement (write side). Stamps the lock on the now-active submission
  858.      * creator. No-op when already locked to the same union_id, so re-auth does NOT extend the window.
  859.      */
  860.     private function applyTikTokIdentityLock(Creator $creator): void
  861.     {
  862.         // Only the active submission row may hold a lock (the read side trusts active rows only).
  863.         // By this point the connected row has been activated by deactivatePreviousPlatformCreators /
  864.         // is a fresh signup; guard defensively so a lock is never stamped on a retired row.
  865.         if (!$creator->isActive()) {
  866.             return;
  867.         }
  868.         $unionId $creator->getTikTokAuth()?->getUnionId();
  869.         if ($unionId === null) {
  870.             return;
  871.         }
  872.         $current $creator->getLockedTikTokUnionId();
  873.         if ($current !== null && $current->equals($unionId)) {
  874.             return; // already locked to this account — keep the original lockedAt
  875.         }
  876.         $creator->setLockedTikTokUnionId($unionId);
  877.         $creator->setLockedAt(new DateTimeImmutable());
  878.         $this->unitOfWork->saveChanges([$creator]);
  879.     }
  880.     /**
  881.      * Bind the OAuth-linked creator row to the same User account as $linkingCreatorId's profile.
  882.      *
  883.      * Legacy rows with null owning_user are claimed once (first linker wins).
  884.      * If owning_user is already set to a different account, callers receive 409 AuthException.
  885.      */
  886.     private function applyPlatformOwningUserClaim(Creator $targetint $linkingCreatorId): void
  887.     {
  888.         $linkProfile $this->get($linkingCreatorId);
  889.         $linkOwnerId $linkProfile->getOwningUserId();
  890.         if ($target->getOwningUser() === null) {
  891.             $target->setOwningUser($this->entityManager->getReference(User::class, $linkOwnerId));
  892.             $this->unitOfWork->saveChanges([$target]);
  893.             return;
  894.         }
  895.         if ($target->getOwningUserId() !== $linkOwnerId) {
  896.             throw new AuthException('This social account is already linked to another user'409);
  897.         }
  898.     }
  899.     private function socialTargetMatchesLinkingProfile(Creator $targetint $linkingCreatorId): bool
  900.     {
  901.         if ($target->getId() === $linkingCreatorId) {
  902.             return true;
  903.         }
  904.         $linkProfile $this->get($linkingCreatorId);
  905.         return $target->getOwningUserId() === $linkProfile->getOwningUserId();
  906.     }
  907.     public function updateStripeConnected(int $userIdbool $stripeConnected): void
  908.     {
  909.         $creator $this->findingRepository->findCreator($userId);
  910.         $dto = new UpdateCreatorStripeConnectedDto(
  911.             $creator,
  912.             $stripeConnected,
  913.         );
  914.         $this->transactionFactory->createTransaction(function () use ($dto) {
  915.             return $this->updatingRepository->updateCreatorStripeConnected($dto);
  916.         })->execute();
  917.     }
  918. }