<?php
namespace App\Service;
use App\Entity\ActivityFeed;
use App\Entity\BlogEntry;
use App\Entity\Bonus;
use App\Entity\Content;
use App\Entity\ContentComment;
use App\Entity\ContentVideo;
use App\Entity\Friendship;
use App\Entity\GuestbookEntry;
use App\Entity\Member;
use App\Repository\ActivityFeedRepository;
use App\Repository\BalanceMemberRepository;
use App\StructSerializer\Main\StructGroup;
use App\StructSerializer\Main\StructOptions;
use App\StructSerializer\Main\StructSerializer;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Frivol\Common\Dict\ActivityFeedType;
use Frivol\Common\Service\CacheService;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Contracts\Cache\ItemInterface;
/**
* @method ActivityFeedRepository getRepository()
*/
class ActivityFeedService extends AbstractService
{
/** @var int How many activities to keep in DB for visitors */
public const MAX_VISITOR_ACTIVITIES = 180;
/** @var int How many activities to keep in DB per Member/Amateur/Operator (excl. profile visits) */
public const MAX_MEMBER_ACTIVITIES = 100;
/** @var int How many activities to keep in DB per Member/Amateur/Operator (only profile visits) */
public const MAX_PROFILE_VISIT_ACTIVITIES = 100;
/**
* @var ActivityFeedRepository
*/
protected $repo;
protected MemberService $memberService;
protected BalanceMemberRepository $balanceMemberRepo;
public function __construct(ActivityFeedRepository $repository, MemberService $memberService, BalanceMemberRepository $bmRepository, private CacheService $cacheService, private StructSerializer $structSerializer)
{
parent::__construct($repository);
$this->memberService = $memberService;
$this->balanceMemberRepo = $bmRepository;
}
public function createEntityByMember(Member $member, ?int $type = null): ActivityFeed
{
$entity = new ActivityFeed();
$entity->setMember($member);
if ($type) {
$entity->setType($type);
}
return $entity;
}
public function createBonusStartedActivity(Member $member, Bonus $bonus): ActivityFeed
{
return $this->createEntityByMember($member, ActivityFeedType::COIN_BONUS_STARTED)->setSpecs([
'id' => $bonus->getId(),
'start' => $bonus->getBonusBegin()->format(\DateTime::ATOM),
'end' => $bonus->getBonusEnd()->format(\DateTime::ATOM),
'percent' => $bonus->getPercent(),
]);
}
public function createGuestbookEntryActivity(GuestbookEntry $entry): ActivityFeed
{
if ($entry->getIsHidden()) {
$type = ActivityFeedType::GUESTBOOK_ENTRY_RECEIVED;
$recipient = $entry->getRecipient();
$sender = $entry->getSender();
} else {
$type = ActivityFeedType::GUESTBOOK_ENTRY_PUBLISHED;
$recipient = null;
$sender = $entry->getRecipient();
}
$af = $this->createEntityByMember($sender, $type);
$af->setGuestbookEntry($entry)
->setCreatedAt($entry->getCreatedAt())
->setVisibleTo($recipient);
return $af;
}
/**
* Keeps ActivityItem state in sync with GuestbookEntry isHidden()/isSpam() state.
*
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function syncGuestbookActivity(GuestbookEntry $entry, bool $flush = true)
{
// There may be a ActivityFeedType::GUESTBOOK_ENTRY or ActivityFeedType::GUESTBOOK_ENTRY_COMMENT present
$presentFeedItems = $this->repo->findByGuestbookEntry($entry->getId());
$activityCount = count($presentFeedItems);
if ($activityCount > 0 && ($entry->getIsHidden() || $entry->getIsSpam())) {
foreach ($presentFeedItems as $activity) {
$this->deleteEntity($activity->getId(), $flush);
}
return;
}
if (2 === $activityCount) {
return;
}
if (false === $entry->getIsHidden() && false === $entry->getIsSpam()) {
if (0 === $activityCount) {
$newItem = $this->createGuestbookEntryActivity($entry);
if (null !== $entry->getComment()) {
$newItem = $this->createGuestbookCommentActivity($entry);
$this->storeEntity($newItem, $flush);
}
$this->storeEntity($newItem, $flush);
return;
}
$activity = array_pop($presentFeedItems);
// Activity item for guestbook comment is missing
if ($activity->isGuestbookEntry() && null !== $entry->getCommentedAt()) {
$newItem = $this->createGuestbookCommentActivity($entry);
$this->storeEntity($newItem, $flush);
return;
}
// Activity item for guestbook entry is missing - probably because of error
if ($activity->isGuestbookComment()) {
$newItem = $this->createGuestbookEntryActivity($entry);
$this->storeEntity($newItem, $flush);
}
}
}
public function createGuestbookCommentActivity(GuestbookEntry $entry): ActivityFeed
{
$af = $this->createEntityByMember($entry->getRecipient(), ActivityFeedType::GUESTBOOK_ENTRY_COMMENT);
if (null !== $entry->getCommentedAt()) {
$createdAt = clone $entry->getCommentedAt();
} else {
$createdAt = clone $entry->getCreatedAt();
$createdAt->modify('+1 minute');
}
$af->setGuestbookEntry($entry)
->setCreatedAt($createdAt)
->setVisibleTo($entry->getSender());
return $af;
}
public function createBlogEntryActivity(BlogEntry $blog): ?ActivityFeed
{
$af = $this->createEntityByMember($blog->getMember(), ActivityFeedType::BLOG_POST);
$af->setBlogEntry($blog)
->setCreatedAt($blog->getPublishAt());
return $af;
}
public function createContentCommentActivity(ContentComment $comment): ?ActivityFeed
{
if (null === $content = $comment->getContent()) {
return null;
}
$af = $this->createEntityByMember($comment->getMember(), ActivityFeedType::CONTENT_COMMENT);
if (null !== $comment->getCreatedAt()) {
$createdAt = clone $comment->getCreatedAt();
} else {
// fallback on content create time when no comment createdAt is given
$createdAt = clone $content->getCreatedAt();
}
$af->setContent($comment->getContent())
->setContentComment($comment)
->setCreatedAt($createdAt);
return $af;
}
public function createFriendshipAskActivity(Member $from, Member $visibleTo): ActivityFeed
{
return $this
->createEntityByMember($from, ActivityFeedType::FRIENDSHIP_ASK)
->setVisibleTo($visibleTo);
}
public function createContentPublishedActivity(Member $from, Content $content): ActivityFeed
{
return $this
->createEntityByMember($from, ActivityFeedType::CONTENT_PUBLISHED)
->setContent($content);
}
public function createContentVideoPublishedActivity(Member $from, Content $content): ?ActivityFeed
{
if (!$content->isVideo()) {
return null;
}
return $this
->createEntityByMember($from, ActivityFeedType::CONTENT_PUBLISHED_VIDEO)
->setContent($content);
}
public function createContentImagesetPublishedActivity(Member $from, Content $content): ?ActivityFeed
{
if (!$content->isImageset()) {
return null;
}
return $this
->createEntityByMember($from, ActivityFeedType::CONTENT_PUBLISHED_IMAGESET)
->setContent($content);
}
public function createContentPurchaseActivity(Member $purchasedBy, Content $content): ActivityFeed
{
return $this->createEntityByMember($purchasedBy, ActivityFeedType::CONTENT_PURCHASE)->setContent($content);
}
public function createMemberActivityActivity(Member $memberWhoIsActive): ActivityFeed
{
return $this->createEntityByMember($memberWhoIsActive, ActivityFeedType::MEMBER_ACTIVITY);
}
public function createContentVideoPurchaseActivity(Member $purchasedBy, Content $content): ?ActivityFeed
{
if (!$content->isVideo()) {
return null;
}
return $this->createEntityByMember($purchasedBy, ActivityFeedType::CONTENT_PURCHASE_VIDEO)->setContent($content);
}
public function createContentImagesetPurchaseActivity(Member $purchasedBy, Content $content): ?ActivityFeed
{
if (!$content->isImageset()) {
return null;
}
return $this->createEntityByMember($purchasedBy, ActivityFeedType::CONTENT_PURCHASE_IMAGESET)->setContent($content);
}
public function createLoginActivity(Member $from): ActivityFeed
{
$activity = $this->createEntityByMember($from, ActivityFeedType::LOGIN);
$activity->setSpecs([
'livecamUsage' => $this->balanceMemberRepo->getLivecamUsageSumByMember($from),
]);
return $activity;
}
public function createWebcamOnlineActivity(Member $from): ActivityFeed
{
return $this->createEntityByMember($from, ActivityFeedType::WEBCAM_ONLINE);
}
public function createRegistrationCompletedActivity(Member $from): ActivityFeed
{
return $this->createEntityByMember($from, ActivityFeedType::REGISTRATION_COMPLETE);
}
public function createFriendLoginActivities(Member $loggedInMember, array $friends): array
{
$activities = [];
/** @var Friendship $friendship */
foreach ($friends as $friendship) {
$targetMember = $friendship->getTargetMember();
if ($targetMember->getId() === $loggedInMember->getId()) {
continue;
}
$newActivity = $this->createEntityByMember($loggedInMember, ActivityFeedType::LOGIN_FRIEND);
$newActivity->setVisibleTo($targetMember);
$activities[] = $newActivity;
}
return $activities;
}
public function createNewMessageActivity(Member $sender, Member $recipient): ActivityFeed
{
return $this->createEntityByMember($sender, ActivityFeedType::MESSENGER_NEW_MESSAGE)->setVisibleTo($recipient);
}
/**
* @throws \Exception
*/
public function getActivityFeedForNonAmateur(Member $member, int $page, int $limit): Paginator
{
$qb = $this->repo->getActivityFeedForNonAmateurQueryBuilder($member->getId());
$qb->setMaxResults($limit);
$qb->setFirstResult($page * $limit - $limit);
return new Paginator($qb->getQuery());
}
/**
* @throws \Exception
*/
public function getActivityFeedForAmateur(Member $member, int $page, int $limit): Paginator
{
$qb = $this->repo->getActivityFeedForAmateurQueryBuilder($member->getId());
$qb->setMaxResults($limit);
$qb->setFirstResult($page * $limit - $limit);
return new Paginator($qb->getQuery());
}
/**
* @throws \Exception
*/
public function getTimelineFeedForMember(Member $member, int $page, int $limit): Paginator
{
$qb = $this->repo->getTimelineFeedForMemberQueryBuilder($member);
$qb->setMaxResults($limit);
$qb->setFirstResult($page * $limit - $limit);
return new Paginator($qb->getQuery());
}
/**
* @return Paginator
*
* @throws \Exception
*/
public function getGlobalTimelineFeedData(int $page, int $limit)
{
$key = (new \ReflectionClass($this))->getShortName().'_'.__FUNCTION__.'_'.$page.'_'.$limit;
return $this->cacheService->get($key, function (ItemInterface $item) use ($page, $limit) {
$item->expiresAfter(300);
$item->tag([
'api-timelinefeed-global',
'api-timelinefeed-global-'.$page,
]);
$qb = $this->repo->getGlobalTimelineFeedQueryBuilder();
$qb->setMaxResults($limit);
$qb->setFirstResult($page * $limit - $limit);
$paginator = new Paginator($qb->getQuery());
$options = StructOptions::create();
$options->setGroupFor(ActivityFeed::class, StructGroup::INFOSTREAM);
$options->setGroupFor(BlogEntry::class, StructGroup::INFOSTREAM);
$options->skipProperty(Content::class, 'comments');
$options->skipProperty(Content::class, 'categories');
$options->skipProperty(Content::class, 'ranking');
$options->skipProperty(Content::class, 'actors');
$options->skipProperty(Content::class, 'video');
$options->skipProperty(Content::class, 'imageset');
$options->skipProperty(Member::class, 'member_online');
$options->skipProperty(Member::class, 'profile_values');
$options->skipProperty(Member::class, 'account');
$options->skipProperty(ContentVideo::class, 'content');
return [
'data' => $this->structSerializer->multipleToArray($options, $paginator),
'total' => $paginator->count(),
];
});
}
public function applyValuesToEntity(ParameterBag $bag, ActivityFeed $entity): ActivityFeed
{
if ($bag->has('type')) {
$entity->setType($bag->getInt('type'));
}
if ($bag->has('specs')) {
$entity->setSpecs($bag->get('specs'));
}
if ($bag->has('visible_to')) {
$memberId = $bag->getInt('visible_to');
$member = $this->memberService->getMemberById($memberId);
if (!$member) {
throw new NotFoundHttpException('Member with ID '.$memberId.' not found.');
}
$entity->setVisibleTo($member);
}
return $entity;
}
/**
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function storeActivity(ActivityFeed $entity): ActivityFeed
{
return $this->storeEntity($entity);
}
public function removeActivityForBlogEntry(BlogEntry $blogEntry): void
{
$af = $this->getRepository()->findOneBy([
'blogEntry' => $blogEntry,
]);
if (null !== $af) {
$this->getRepository()->removeEntity($af, false);
}
if ($blogEntry->getActivityFeedId() > 0) {
$blogEntry->setActivityFeedId(null);
}
$this->getRepository()->flush();
}
/**
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function removeActivitiesForContent(Content $content): int
{
/**
* @var $items ActivityFeed[]
*/
$items = $this->getRepository()->findBy([
'content' => $content,
]);
$removed = 0;
foreach ($items as $activity) {
$response = $this->removeEntity($activity);
if ($response->getSuccess()) {
++$removed;
}
}
return $removed;
}
public function removeActivitiesForGuestbookEntry(GuestbookEntry $entry): int
{
$removed = 0;
$activities = $this->getRepository()->findBy(['guestbookEntry' => $entry]);
/** @var ActivityFeed $activity */
foreach ($activities as $activity) {
if ($this->removeEntity($activity)->getSuccess()) {
++$removed;
}
}
return $removed;
}
public function canStoreNewActivity(int $memberId, int $activityType, \DateTime $since = new \DateTime('-1 minute')): bool
{
switch ($activityType) {
case ActivityFeedType::LOGIN:
$maxAllowedSince = 1;
break;
default:
$maxAllowedSince = null;
}
if (null === $maxAllowedSince) {
return true;
}
return $this->repo->countActivitiesFromMemberSince($memberId, $activityType, $since) < $maxAllowedSince;
}
public function getProfileVisitActivities(array $memberIds, ?\DateTime $fromDateTime = null): array
{
$activities = $this->repo->getProfileVisitActivities($memberIds, $fromDateTime);
$this->limitMaxElementCount($activities, self::MAX_PROFILE_VISIT_ACTIVITIES);
return $activities;
}
public function getMemberInfostreamActivities(array $memberActivityIds = []): array
{
$memberIds = array_keys($memberActivityIds);
$memberIsAmateurMap = $this->memberService->getMembersAmateurStatus($memberIds);
$activities = [];
// Not a good idea to do this kind of request for every online member.
foreach ($memberActivityIds as $memberId => $minActivityId) {
if ($memberIsAmateurMap[$memberId] ?? false) {
$activities[$memberId] = $this->repo->getActivityFeedForAmateurQueryBuilder($memberId, $minActivityId)->getQuery()->getResult();
continue;
}
$activities[$memberId] = $this->repo->getActivityFeedForNonAmateurQueryBuilder($memberId, $minActivityId)->getQuery()->getResult();
}
return $activities;
}
public function getPublicInfostreamActivities(): array
{
return $this->repo->getPublicInfostream();
}
private function limitMaxElementCount(array &$activities, int $maxPerMember): void
{
$countsByMemberId = [];
foreach ($activities as $key => $activity) {
$currentMemberId = $activity->getVisibleTo()?->getId();
if (!$currentMemberId || (isset($countsByMemberId[$currentMemberId]) && $countsByMemberId[$currentMemberId] > $maxPerMember)) {
unset($activities[$key]);
continue;
}
if (!isset($countsByMemberId[$currentMemberId])) {
$countsByMemberId[$currentMemberId] = 1;
} else {
++$countsByMemberId[$currentMemberId];
}
}
$activities = array_values($activities);
}
}