feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
This commit is contained in:
115
app/Services/Posts/NotificationDigestService.php
Normal file
115
app/Services/Posts/NotificationDigestService.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Collection as BaseCollection;
|
||||
|
||||
/**
|
||||
* Aggregates database notification records into digest groups
|
||||
* so the UI shows "12 people liked your post" instead of 12 separate entries.
|
||||
*
|
||||
* Grouping key: (notifiable_id, data->type, data->post_id, 1-hour bucket)
|
||||
*/
|
||||
class NotificationDigestService
|
||||
{
|
||||
/**
|
||||
* Aggregate a raw notification collection into digest entries.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection $notifications
|
||||
* @return array<array{
|
||||
* type: string,
|
||||
* count: int,
|
||||
* actors: array,
|
||||
* post_id: int|null,
|
||||
* message: string,
|
||||
* url: string|null,
|
||||
* latest_at: string,
|
||||
* read: bool,
|
||||
* ids: int[],
|
||||
* }>
|
||||
*/
|
||||
public function aggregate(Collection $notifications): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($notifications as $notif) {
|
||||
$data = is_array($notif->data) ? $notif->data : json_decode($notif->data, true);
|
||||
$type = $data['type'] ?? 'unknown';
|
||||
$postId = $data['post_id'] ?? null;
|
||||
|
||||
// 1-hour bucket
|
||||
$bucket = $notif->created_at->format('Y-m-d H');
|
||||
|
||||
$key = "{$type}:{$postId}:{$bucket}";
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'type' => $type,
|
||||
'count' => 0,
|
||||
'actors' => [],
|
||||
'post_id' => $postId,
|
||||
'url' => $data['url'] ?? null,
|
||||
'latest_at' => $notif->created_at->toISOString(),
|
||||
'read' => $notif->read_at !== null,
|
||||
'ids' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$groups[$key]['count']++;
|
||||
$groups[$key]['ids'][] = $notif->id;
|
||||
|
||||
// Collect unique actors (up to 5 for display)
|
||||
$actorId = $data['commenter_id'] ?? $data['reactor_id'] ?? $data['actor_id'] ?? null;
|
||||
if ($actorId && count($groups[$key]['actors']) < 5) {
|
||||
$alreadyAdded = collect($groups[$key]['actors'])->contains('id', $actorId);
|
||||
if (! $alreadyAdded) {
|
||||
$groups[$key]['actors'][] = [
|
||||
'id' => $actorId,
|
||||
'name' => $data['commenter_name'] ?? $data['actor_name'] ?? null,
|
||||
'username' => $data['commenter_username'] ?? $data['actor_username'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($notif->read_at === null) {
|
||||
$groups[$key]['read'] = false; // group is unread if any item is unread
|
||||
}
|
||||
}
|
||||
|
||||
// Build readable message for each group
|
||||
$result = [];
|
||||
foreach ($groups as $group) {
|
||||
$group['message'] = $this->buildMessage($group);
|
||||
$result[] = $group;
|
||||
}
|
||||
|
||||
// Sort by latest_at descending
|
||||
usort($result, fn ($a, $b) => strcmp($b['latest_at'], $a['latest_at']));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function buildMessage(array $group): string
|
||||
{
|
||||
$count = $group['count'];
|
||||
$actors = $group['actors'];
|
||||
$first = $actors[0]['name'] ?? 'Someone';
|
||||
$others = $count - 1;
|
||||
|
||||
return match ($group['type']) {
|
||||
'post_commented' => $count === 1
|
||||
? "{$first} commented on your post"
|
||||
: "{$first} and {$others} other(s) commented on your post",
|
||||
'post_liked' => $count === 1
|
||||
? "{$first} liked your post"
|
||||
: "{$first} and {$others} other(s) liked your post",
|
||||
'post_shared' => $count === 1
|
||||
? "{$first} shared your artwork"
|
||||
: "{$first} and {$others} other(s) shared your artwork",
|
||||
default => $count === 1
|
||||
? "{$first} interacted with your post"
|
||||
: "{$count} people interacted with your post",
|
||||
};
|
||||
}
|
||||
}
|
||||
109
app/Services/Posts/PostAchievementService.php
Normal file
109
app/Services/Posts/PostAchievementService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Creates achievement posts for significant milestones.
|
||||
*
|
||||
* Achievement types stored in post.meta.achievement_type:
|
||||
* - follower_100 / follower_500 / follower_1000 / follower_5000 / follower_10000
|
||||
* - artwork_100_views / artwork_1000_views / artwork_1000_favs
|
||||
* - award_received
|
||||
*/
|
||||
class PostAchievementService
|
||||
{
|
||||
private const FOLLOWER_MILESTONES = [100, 500, 1_000, 5_000, 10_000, 50_000];
|
||||
private const VIEW_MILESTONES = [100, 1_000, 10_000, 100_000];
|
||||
private const FAV_MILESTONES = [10, 100, 500, 1_000];
|
||||
|
||||
/**
|
||||
* Check if a follower count crosses a milestone and create an achievement post.
|
||||
*/
|
||||
public function maybeFollowerMilestone(User $user, int $newFollowerCount): void
|
||||
{
|
||||
foreach (self::FOLLOWER_MILESTONES as $milestone) {
|
||||
if ($newFollowerCount === $milestone) {
|
||||
$this->createAchievementPost($user, "follower_{$milestone}", [
|
||||
'milestone' => $milestone,
|
||||
'message' => "🎉 Just reached {$milestone} followers! Thank you all!",
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an artwork's view count crosses a milestone.
|
||||
*/
|
||||
public function maybeArtworkViewMilestone(User $user, int $artworkId, int $newViewCount): void
|
||||
{
|
||||
foreach (self::VIEW_MILESTONES as $milestone) {
|
||||
if ($newViewCount === $milestone) {
|
||||
$this->createAchievementPost($user, "artwork_{$milestone}_views", [
|
||||
'artwork_id' => $artworkId,
|
||||
'milestone' => $milestone,
|
||||
'message' => "🎨 One of my artworks just hit {$milestone} views!",
|
||||
], $artworkId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an achievement post for receiving an award.
|
||||
*/
|
||||
public function awardReceived(User $user, string $awardName, ?int $artworkId = null): void
|
||||
{
|
||||
$this->createAchievementPost($user, 'award_received', [
|
||||
'award_name' => $awardName,
|
||||
'artwork_id' => $artworkId,
|
||||
'message' => "🏆 Just received the \"{$awardName}\" award!",
|
||||
], $artworkId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function createAchievementPost(
|
||||
User $user,
|
||||
string $achievementType,
|
||||
array $meta,
|
||||
?int $artworkId = null,
|
||||
): void {
|
||||
// Deduplicate: don't create the same achievement post twice
|
||||
$exists = Post::where('user_id', $user->id)
|
||||
->where('type', Post::TYPE_ACHIEVEMENT)
|
||||
->whereJsonContains('meta->achievement_type', $achievementType)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $achievementType, $meta, $artworkId) {
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => Post::TYPE_ACHIEVEMENT,
|
||||
'visibility' => Post::VISIBILITY_PUBLIC,
|
||||
'body' => $meta['message'] ?? null,
|
||||
'status' => Post::STATUS_PUBLISHED,
|
||||
'meta' => array_merge($meta, ['achievement_type' => $achievementType]),
|
||||
]);
|
||||
|
||||
if ($artworkId) {
|
||||
PostTarget::create([
|
||||
'post_id' => $post->id,
|
||||
'target_type' => 'artwork',
|
||||
'target_id' => $artworkId,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
Log::info("PostAchievementService: created '{$achievementType}' post for user #{$user->id}");
|
||||
}
|
||||
}
|
||||
81
app/Services/Posts/PostAnalyticsService.php
Normal file
81
app/Services/Posts/PostAnalyticsService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Tracks post impressions (throttled per session) and computes engagement score.
|
||||
*
|
||||
* Impression throttle: 1 impression per post per session-key per hour.
|
||||
* Engagement score: (reactions*2 + comments*3 + saves) / max(impressions, 1)
|
||||
*/
|
||||
class PostAnalyticsService
|
||||
{
|
||||
/**
|
||||
* Record a post impression, throttled by a session key.
|
||||
* Returns true if impression was counted, false if throttled.
|
||||
*/
|
||||
public function trackImpression(Post $post, string $sessionKey): bool
|
||||
{
|
||||
$cacheKey = "impression:{$post->id}:{$sessionKey}";
|
||||
|
||||
if (Cache::has($cacheKey)) {
|
||||
return false; // already counted this hour
|
||||
}
|
||||
|
||||
Cache::put($cacheKey, 1, now()->addHour());
|
||||
|
||||
Post::withoutTimestamps(function () use ($post) {
|
||||
DB::table('posts')
|
||||
->where('id', $post->id)
|
||||
->increment('impressions_count');
|
||||
});
|
||||
|
||||
// Recompute engagement score asynchronously via a quick DB update
|
||||
$this->refreshEngagementScore($post->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the cached engagement_score = (reactions*2 + comments*3 + saves) / max(impressions, 1)
|
||||
*/
|
||||
public function refreshEngagementScore(int $postId): void
|
||||
{
|
||||
Post::withoutTimestamps(function () use ($postId) {
|
||||
DB::table('posts')
|
||||
->where('id', $postId)
|
||||
->update([
|
||||
'engagement_score' => DB::raw(
|
||||
'(reactions_count * 2 + comments_count * 3 + saves_count) / GREATEST(impressions_count, 1)'
|
||||
),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return analytics summary for a post (owner view).
|
||||
*/
|
||||
public function getSummary(Post $post): array
|
||||
{
|
||||
$reactions = $post->reactions_count;
|
||||
$comments = $post->comments_count;
|
||||
$saves = $post->saves_count;
|
||||
$impressions = $post->impressions_count;
|
||||
$rate = $impressions > 0
|
||||
? round((($reactions + $comments + $saves) / $impressions) * 100, 2)
|
||||
: 0.0;
|
||||
|
||||
return [
|
||||
'impressions' => $impressions,
|
||||
'reactions' => $reactions,
|
||||
'comments' => $comments,
|
||||
'saves' => $saves,
|
||||
'engagement_rate' => $rate, // percentage
|
||||
'engagement_score' => round($post->engagement_score, 4),
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Services/Posts/PostCountersService.php
Normal file
68
app/Services/Posts/PostCountersService.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\PostReaction;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Maintains cached counter columns on the posts table.
|
||||
* Called from controllers when reactions/comments are created or deleted.
|
||||
*/
|
||||
class PostCountersService
|
||||
{
|
||||
public function incrementReactions(Post $post): void
|
||||
{
|
||||
$post->increment('reactions_count');
|
||||
}
|
||||
|
||||
public function decrementReactions(Post $post): void
|
||||
{
|
||||
DB::table('posts')
|
||||
->where('id', $post->id)
|
||||
->where('reactions_count', '>', 0)
|
||||
->decrement('reactions_count');
|
||||
}
|
||||
|
||||
public function incrementComments(Post $post): void
|
||||
{
|
||||
$post->increment('comments_count');
|
||||
}
|
||||
|
||||
public function decrementComments(Post $post): void
|
||||
{
|
||||
DB::table('posts')
|
||||
->where('id', $post->id)
|
||||
->where('comments_count', '>', 0)
|
||||
->decrement('comments_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute both counters from scratch (repair tool).
|
||||
*/
|
||||
public function recompute(Post $post): void
|
||||
{
|
||||
Post::withoutTimestamps(function () use ($post) {
|
||||
$post->update([
|
||||
'reactions_count' => PostReaction::where('post_id', $post->id)->count(),
|
||||
'comments_count' => PostComment::where('post_id', $post->id)->whereNull('deleted_at')->count(),
|
||||
'saves_count' => \App\Models\PostSave::where('post_id', $post->id)->count(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function incrementSaves(Post $post): void
|
||||
{
|
||||
$post->increment('saves_count');
|
||||
}
|
||||
|
||||
public function decrementSaves(Post $post): void
|
||||
{
|
||||
DB::table('posts')
|
||||
->where('id', $post->id)
|
||||
->where('saves_count', '>', 0)
|
||||
->decrement('saves_count');
|
||||
}
|
||||
}
|
||||
262
app/Services/Posts/PostFeedService.php
Normal file
262
app/Services/Posts/PostFeedService.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostSave;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PostFeedService
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Profile feed — pinned posts first, then chronological
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getProfileFeed(
|
||||
User $profileUser,
|
||||
?int $viewerId,
|
||||
int $page = 1,
|
||||
): array {
|
||||
$baseQuery = Post::with($this->eagerLoads())
|
||||
->where('user_id', $profileUser->id)
|
||||
->visibleTo($viewerId);
|
||||
|
||||
// Pinned posts (always on page 1, regardless of pagination)
|
||||
$pinned = (clone $baseQuery)
|
||||
->where('is_pinned', true)
|
||||
->orderBy('pinned_order')
|
||||
->get();
|
||||
|
||||
$paginated = (clone $baseQuery)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(self::PER_PAGE, ['*'], 'page', $page);
|
||||
|
||||
// On page 1, prepend pinned posts (deduplicated)
|
||||
$paginatedCollection = $paginated->getCollection();
|
||||
if ($page === 1 && $pinned->isNotEmpty()) {
|
||||
$pinnedIds = $pinned->pluck('id');
|
||||
$rest = $paginatedCollection->reject(fn ($p) => $pinnedIds->contains($p->id));
|
||||
$combined = $pinned->concat($rest);
|
||||
} else {
|
||||
$combined = $paginatedCollection->reject(fn ($p) => $p->is_pinned && $page === 1);
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $combined->values()->all(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Following feed (ranked + diversity-limited)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getFollowingFeed(
|
||||
User $viewer,
|
||||
int $page = 1,
|
||||
string $filter = 'all',
|
||||
): array {
|
||||
$followingIds = DB::table('user_followers')
|
||||
->where('follower_id', $viewer->id)
|
||||
->pluck('user_id')
|
||||
->toArray();
|
||||
|
||||
if (empty($followingIds)) {
|
||||
return ['data' => [], 'meta' => ['total' => 0, 'current_page' => $page, 'last_page' => 1, 'per_page' => self::PER_PAGE]];
|
||||
}
|
||||
|
||||
$query = Post::with($this->eagerLoads())
|
||||
->whereIn('user_id', $followingIds)
|
||||
->visibleTo($viewer->id)
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($filter === 'shares') $query->where('type', Post::TYPE_ARTWORK_SHARE);
|
||||
elseif ($filter === 'text') $query->where('type', Post::TYPE_TEXT);
|
||||
elseif ($filter === 'uploads') $query->where('type', Post::TYPE_UPLOAD);
|
||||
|
||||
$paginated = $query->paginate(self::PER_PAGE, ['*'], 'page', $page);
|
||||
$diversified = $this->applyDiversityPass($paginated->getCollection());
|
||||
|
||||
return [
|
||||
'data' => $diversified->values()->all(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Hashtag feed
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getHashtagFeed(
|
||||
string $tag,
|
||||
?int $viewerId,
|
||||
int $page = 1,
|
||||
): array {
|
||||
$tag = mb_strtolower($tag);
|
||||
|
||||
$paginated = Post::with($this->eagerLoads())
|
||||
->whereHas('hashtags', fn ($q) => $q->where('tag', $tag))
|
||||
->visibleTo($viewerId)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(self::PER_PAGE, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'data' => $paginated->getCollection()->values()->all(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Saved posts feed
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getSavedFeed(User $viewer, int $page = 1): array
|
||||
{
|
||||
$paginated = Post::with($this->eagerLoads())
|
||||
->whereHas('saves', fn ($q) => $q->where('user_id', $viewer->id))
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(self::PER_PAGE, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'data' => $paginated->getCollection()->values()->all(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Eager loads for authenticated/profile feeds. */
|
||||
private function eagerLoads(): array
|
||||
{
|
||||
return [
|
||||
'user',
|
||||
'user.profile',
|
||||
'targets',
|
||||
'targets.artwork',
|
||||
'targets.artwork.user',
|
||||
'targets.artwork.user.profile',
|
||||
'reactions',
|
||||
'hashtags',
|
||||
];
|
||||
}
|
||||
|
||||
/** Eager loads safe for public (trending) feed calls from PostTrendingService. */
|
||||
public function publicEagerLoads(): array
|
||||
{
|
||||
return $this->eagerLoads();
|
||||
}
|
||||
|
||||
/**
|
||||
* Penalize runs of 5+ posts from the same author by deferring them to the end.
|
||||
*/
|
||||
public function applyDiversityPass(Collection $posts): Collection
|
||||
{
|
||||
$result = collect(); $deferred = collect(); $runCounts = [];
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$uid = $post->user_id;
|
||||
$runCounts[$uid] = ($runCounts[$uid] ?? 0) + 1;
|
||||
($runCounts[$uid] <= 5 ? $result : $deferred)->push($post);
|
||||
}
|
||||
|
||||
return $result->merge($deferred);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Formatter
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize a Post into a JSON-safe array for API responses.
|
||||
*/
|
||||
public function formatPost(Post $post, ?int $viewerId): array
|
||||
{
|
||||
$artworkData = null;
|
||||
|
||||
if ($post->type === Post::TYPE_ARTWORK_SHARE) {
|
||||
$target = $post->targets->firstWhere('target_type', 'artwork');
|
||||
$artwork = $target?->artwork;
|
||||
if ($artwork) {
|
||||
$artworkData = [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumb_url ?? null,
|
||||
'author' => [
|
||||
'id' => $artwork->user->id,
|
||||
'username' => $artwork->user->username,
|
||||
'name' => $artwork->user->name,
|
||||
'avatar' => $artwork->user->profile?->avatar_url ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$viewerLiked = $viewerSaved = false;
|
||||
if ($viewerId) {
|
||||
$viewerLiked = $post->reactions->where('user_id', $viewerId)->where('reaction', 'like')->isNotEmpty();
|
||||
// saves are lazy-loaded only when needed; check if relation is loaded
|
||||
if ($post->relationLoaded('saves')) {
|
||||
$viewerSaved = $post->saves->where('user_id', $viewerId)->isNotEmpty();
|
||||
} else {
|
||||
$viewerSaved = $post->saves()->where('user_id', $viewerId)->exists();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $post->id,
|
||||
'type' => $post->type,
|
||||
'visibility' => $post->visibility,
|
||||
'status' => $post->status,
|
||||
'body' => $post->body,
|
||||
'reactions_count' => $post->reactions_count,
|
||||
'comments_count' => $post->comments_count,
|
||||
'saves_count' => $post->saves_count,
|
||||
'impressions_count'=> $post->impressions_count,
|
||||
'is_pinned' => (bool) $post->is_pinned,
|
||||
'pinned_order' => $post->pinned_order,
|
||||
'publish_at' => $post->publish_at?->toISOString(),
|
||||
'viewer_liked' => $viewerLiked,
|
||||
'viewer_saved' => $viewerSaved,
|
||||
'artwork' => $artworkData,
|
||||
'author' => [
|
||||
'id' => $post->user->id,
|
||||
'username' => $post->user->username,
|
||||
'name' => $post->user->name,
|
||||
'avatar' => $post->user->profile?->avatar_url ?? null,
|
||||
],
|
||||
'hashtags' => $post->relationLoaded('hashtags') ? $post->hashtags->pluck('tag')->toArray() : [],
|
||||
'meta' => $post->meta,
|
||||
'created_at' => $post->created_at->toISOString(),
|
||||
'updated_at' => $post->updated_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
86
app/Services/Posts/PostHashtagService.php
Normal file
86
app/Services/Posts/PostHashtagService.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostHashtag;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Parses hashtags from post body and syncs them to the post_hashtags table.
|
||||
*/
|
||||
class PostHashtagService
|
||||
{
|
||||
/** Regex: #word (letters/numbers/underscore, 2–64 chars, no leading digit) */
|
||||
private const HASHTAG_RE = '/#([A-Za-z][A-Za-z0-9_]{1,63})/u';
|
||||
|
||||
/**
|
||||
* Extract unique lowercase hashtag strings from raw or HTML body.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function parseHashtags(string $body): array
|
||||
{
|
||||
$plainText = strip_tags($body);
|
||||
preg_match_all(self::HASHTAG_RE, $plainText, $matches);
|
||||
|
||||
return array_values(array_unique(
|
||||
array_map('mb_strtolower', $matches[1] ?? [])
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync hashtags for a post: insert new, delete removed.
|
||||
*/
|
||||
public function sync(Post $post, string $body): void
|
||||
{
|
||||
$tags = $this->parseHashtags($body);
|
||||
|
||||
DB::transaction(function () use ($post, $tags) {
|
||||
// Remove tags no longer in the body
|
||||
PostHashtag::where('post_id', $post->id)
|
||||
->whereNotIn('tag', $tags)
|
||||
->delete();
|
||||
|
||||
// Insert new tags (ignore duplicates)
|
||||
foreach ($tags as $tag) {
|
||||
PostHashtag::firstOrCreate([
|
||||
'post_id' => $post->id,
|
||||
'tag' => $tag,
|
||||
], [
|
||||
'user_id' => $post->user_id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending hashtags in the last $hours hours (top $limit by post count).
|
||||
*
|
||||
* @return array<array{tag: string, post_count: int, author_count: int}>
|
||||
*/
|
||||
public function trending(int $limit = 10, int $hours = 24): array
|
||||
{
|
||||
return DB::table('post_hashtags')
|
||||
->join('posts', 'post_hashtags.post_id', '=', 'posts.id')
|
||||
->where('post_hashtags.created_at', '>=', now()->subHours($hours))
|
||||
->where('posts.status', Post::STATUS_PUBLISHED)
|
||||
->where('posts.visibility', Post::VISIBILITY_PUBLIC)
|
||||
->whereNull('posts.deleted_at')
|
||||
->groupBy('post_hashtags.tag')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->limit($limit)
|
||||
->get([
|
||||
'post_hashtags.tag',
|
||||
DB::raw('COUNT(*) as post_count'),
|
||||
DB::raw('COUNT(DISTINCT post_hashtags.user_id) as author_count'),
|
||||
])
|
||||
->map(fn ($row) => [
|
||||
'tag' => $row->tag,
|
||||
'post_count' => (int) $row->post_count,
|
||||
'author_count' => (int) $row->author_count,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
115
app/Services/Posts/PostService.php
Normal file
115
app/Services/Posts/PostService.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
use App\Services\ContentSanitizer;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PostService
|
||||
{
|
||||
public function __construct(
|
||||
private PostCountersService $counters,
|
||||
private PostHashtagService $hashtags,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new text post.
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $visibility
|
||||
* @param string|null $body
|
||||
* @param array $targets [['type'=>'artwork','id'=>123], ...]
|
||||
* @return Post
|
||||
*/
|
||||
public function createPost(
|
||||
User $user,
|
||||
string $type,
|
||||
string $visibility,
|
||||
?string $body,
|
||||
array $targets = [],
|
||||
?array $linkPreview = null,
|
||||
?array $taggedUsers = null,
|
||||
?Carbon $publishAt = null,
|
||||
): Post {
|
||||
$sanitizedBody = $body ? ContentSanitizer::render($body) : null;
|
||||
|
||||
$status = ($publishAt && $publishAt->isFuture())
|
||||
? Post::STATUS_SCHEDULED
|
||||
: Post::STATUS_PUBLISHED;
|
||||
|
||||
$meta = [];
|
||||
if ($linkPreview && ! empty($linkPreview['url'])) {
|
||||
$meta['link_preview'] = array_intersect_key($linkPreview, array_flip(['url', 'title', 'description', 'image', 'site_name']));
|
||||
}
|
||||
if ($taggedUsers && count($taggedUsers) > 0) {
|
||||
$meta['tagged_users'] = array_map(
|
||||
fn ($u) => ['id' => (int) $u['id'], 'username' => (string) $u['username'], 'name' => (string) ($u['name'] ?? $u['username'])],
|
||||
array_slice($taggedUsers, 0, 10),
|
||||
);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $type, $visibility, $sanitizedBody, $targets, $meta, $status, $publishAt) {
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => $type,
|
||||
'visibility' => $visibility,
|
||||
'body' => $sanitizedBody,
|
||||
'meta' => $meta ?: null,
|
||||
'status' => $status,
|
||||
'publish_at' => $publishAt,
|
||||
]);
|
||||
|
||||
foreach ($targets as $target) {
|
||||
PostTarget::create([
|
||||
'post_id' => $post->id,
|
||||
'target_type' => $target['type'],
|
||||
'target_id' => $target['id'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Sync hashtags extracted from the body
|
||||
if ($sanitizedBody) {
|
||||
$this->hashtags->sync($post, $sanitizedBody);
|
||||
}
|
||||
|
||||
return $post;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update body/visibility of an existing post.
|
||||
*/
|
||||
public function updatePost(Post $post, ?string $body, ?string $visibility): Post
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
if ($body !== null) {
|
||||
$updates['body'] = ContentSanitizer::render($body);
|
||||
}
|
||||
|
||||
if ($visibility !== null) {
|
||||
$updates['visibility'] = $visibility;
|
||||
}
|
||||
|
||||
$post->update($updates);
|
||||
|
||||
// Re-sync hashtags whenever body changes
|
||||
if (isset($updates['body'])) {
|
||||
$this->hashtags->sync($post, $updates['body']);
|
||||
}
|
||||
|
||||
return $post->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a post (cascades to targets/reactions/comments via DB).
|
||||
*/
|
||||
public function deletePost(Post $post): void
|
||||
{
|
||||
$post->delete();
|
||||
}
|
||||
}
|
||||
72
app/Services/Posts/PostShareService.php
Normal file
72
app/Services/Posts/PostShareService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
use App\Services\ContentSanitizer;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PostShareService
|
||||
{
|
||||
/**
|
||||
* Share an artwork to a user's profile feed.
|
||||
*
|
||||
* Enforces:
|
||||
* - artwork must be public
|
||||
* - no duplicate share within 24 hours (for same user+artwork)
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function shareArtwork(
|
||||
User $user,
|
||||
Artwork $artwork,
|
||||
?string $body,
|
||||
string $visibility = Post::VISIBILITY_PUBLIC,
|
||||
): Post {
|
||||
// Ensure artwork is shareable
|
||||
if (! $artwork->is_public || ! $artwork->is_approved) {
|
||||
throw ValidationException::withMessages([
|
||||
'artwork_id' => ['This artwork cannot be shared because it is not publicly available.'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Duplicate share prevention: same user + same artwork within 24h
|
||||
$alreadyShared = Post::where('user_id', $user->id)
|
||||
->where('type', Post::TYPE_ARTWORK_SHARE)
|
||||
->where('created_at', '>=', Carbon::now()->subHours(24))
|
||||
->whereHas('targets', function ($q) use ($artwork) {
|
||||
$q->where('target_type', 'artwork')->where('target_id', $artwork->id);
|
||||
})
|
||||
->exists();
|
||||
|
||||
if ($alreadyShared) {
|
||||
throw ValidationException::withMessages([
|
||||
'artwork_id' => ['You already shared this artwork recently. Please wait 24 hours before sharing it again.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$sanitizedBody = $body ? ContentSanitizer::render($body) : null;
|
||||
|
||||
return DB::transaction(function () use ($user, $artwork, $sanitizedBody, $visibility) {
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => Post::TYPE_ARTWORK_SHARE,
|
||||
'visibility' => $visibility,
|
||||
'body' => $sanitizedBody,
|
||||
]);
|
||||
|
||||
PostTarget::create([
|
||||
'post_id' => $post->id,
|
||||
'target_type' => 'artwork',
|
||||
'target_id' => $artwork->id,
|
||||
]);
|
||||
|
||||
return $post;
|
||||
});
|
||||
}
|
||||
}
|
||||
146
app/Services/Posts/PostTrendingService.php
Normal file
146
app/Services/Posts/PostTrendingService.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Computes the trending post feed with Wilson/decay scoring and author diversity.
|
||||
*
|
||||
* Score formula (per spec):
|
||||
* base = (likes * 3) + (comments * 5) + (shares * 6) + (unique_reactors * 4)
|
||||
* score = base * exp(-hours_since_post / 24)
|
||||
*
|
||||
* Diversity rule: max 2 posts per author in the top N results.
|
||||
* Cache TTL: 2 minutes.
|
||||
*/
|
||||
class PostTrendingService
|
||||
{
|
||||
private const CACHE_KEY = 'feed:trending';
|
||||
private const CACHE_TTL = 120; // seconds
|
||||
private const WINDOW_DAYS = 7;
|
||||
private const MAX_PER_AUTHOR = 2;
|
||||
|
||||
private PostFeedService $feedService;
|
||||
|
||||
public function __construct(PostFeedService $feedService)
|
||||
{
|
||||
$this->feedService = $feedService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return trending posts for the given viewer.
|
||||
*
|
||||
* @param int|null $viewerId
|
||||
* @param int $page
|
||||
* @param int $perPage
|
||||
* @return array{data: array, meta: array}
|
||||
*/
|
||||
public function getTrending(?int $viewerId, int $page = 1, int $perPage = 20): array
|
||||
{
|
||||
$rankedIds = $this->getRankedIds();
|
||||
|
||||
// Paginate from the ranked ID list
|
||||
$total = count($rankedIds);
|
||||
$pageIds = array_slice($rankedIds, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
if (empty($pageIds)) {
|
||||
return ['data' => [], 'meta' => ['total' => $total, 'current_page' => $page, 'last_page' => (int) ceil($total / $perPage) ?: 1, 'per_page' => $perPage]];
|
||||
}
|
||||
|
||||
// Load posts preserving ranked order
|
||||
$posts = Post::with($this->feedService->publicEagerLoads())
|
||||
->whereIn('id', $pageIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$ordered = array_filter(array_map(fn ($id) => $posts->get($id), $pageIds));
|
||||
$data = array_values(array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewerId),
|
||||
$ordered,
|
||||
));
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($total / $perPage) ?: 1,
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or compute the ranked post-ID list from cache.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function getRankedIds(): array
|
||||
{
|
||||
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
|
||||
return $this->computeRankedIds();
|
||||
});
|
||||
}
|
||||
|
||||
/** Force a cache refresh (called by the CLI command). */
|
||||
public function refresh(): array
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
return $this->getRankedIds();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function computeRankedIds(): array
|
||||
{
|
||||
$cutoff = now()->subDays(self::WINDOW_DAYS);
|
||||
|
||||
$rows = DB::table('posts')
|
||||
->leftJoin(
|
||||
DB::raw('(SELECT post_id, COUNT(*) as unique_reactors FROM post_reactions GROUP BY post_id) pr'),
|
||||
'posts.id', '=', 'pr.post_id',
|
||||
)
|
||||
->where('posts.status', Post::STATUS_PUBLISHED)
|
||||
->where('posts.visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where('posts.created_at', '>=', $cutoff)
|
||||
->whereNull('posts.deleted_at')
|
||||
->select([
|
||||
'posts.id',
|
||||
'posts.user_id',
|
||||
'posts.reactions_count',
|
||||
'posts.comments_count',
|
||||
'posts.created_at',
|
||||
DB::raw('COALESCE(pr.unique_reactors, 0) as unique_reactors'),
|
||||
])
|
||||
->get();
|
||||
|
||||
$now = now()->timestamp;
|
||||
|
||||
$scored = $rows->map(function ($row) use ($now) {
|
||||
$hoursSince = ($now - strtotime($row->created_at)) / 3600;
|
||||
$base = ($row->reactions_count * 3)
|
||||
+ ($row->comments_count * 5)
|
||||
+ ($row->unique_reactors * 4);
|
||||
$score = $base * exp(-$hoursSince / 24);
|
||||
|
||||
return ['id' => $row->id, 'user_id' => $row->user_id, 'score' => $score];
|
||||
})->sortByDesc('score');
|
||||
|
||||
// Apply author diversity: max MAX_PER_AUTHOR posts per author
|
||||
$authorCount = [];
|
||||
$result = [];
|
||||
|
||||
foreach ($scored as $item) {
|
||||
$uid = $item['user_id'];
|
||||
$authorCount[$uid] = ($authorCount[$uid] ?? 0) + 1;
|
||||
if ($authorCount[$uid] <= self::MAX_PER_AUTHOR) {
|
||||
$result[] = $item['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user