Skip to content

Commit d9d27f2

Browse files
committed
feat: implement post navigation service for relevance-based navigation
1 parent 1c9f35b commit d9d27f2

File tree

2 files changed

+150
-3
lines changed

2 files changed

+150
-3
lines changed

src/Http/Actions/Post/PostGetNavigateAction.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
namespace CSlant\Blog\Api\Http\Actions\Post;
44

5-
use Botble\Blog\Repositories\Interfaces\PostInterface;
65
use CSlant\Blog\Api\Http\Resources\Post\PostNavigateResource;
6+
use CSlant\Blog\Api\Services\PostNavigationService;
77
use CSlant\Blog\Core\Facades\Base\SlugHelper;
88
use CSlant\Blog\Core\Http\Responses\Base\BaseHttpResponse;
99
use CSlant\Blog\Core\Models\Post;
@@ -28,7 +28,7 @@
2828
class PostGetNavigateAction
2929
{
3030
public function __construct(
31-
protected PostInterface $postRepository
31+
protected PostNavigationService $postNavigationService
3232
) {
3333
}
3434

@@ -82,7 +82,7 @@ public function __invoke(string $slug): BaseHttpResponse|JsonResponse|JsonResour
8282
->setMessage('Post not found');
8383
}
8484

85-
$navigationPosts = $this->postRepository->getNavigatePosts($slug->reference_id);
85+
$navigationPosts = $this->postNavigationService->getNavigatePosts($slug->reference_id);
8686

8787
return $this
8888
->httpResponse()
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php
2+
3+
namespace CSlant\Blog\Api\Services;
4+
5+
use Botble\Blog\Repositories\Interfaces\PostInterface;
6+
use CSlant\Blog\Core\Http\Responses\Base\BaseHttpResponse;
7+
8+
/**
9+
* Class PostNavigationService
10+
*
11+
* @package CSlant\Blog\Api\Services
12+
*
13+
* @method BaseHttpResponse httpResponse()
14+
*/
15+
class PostNavigationService
16+
{
17+
public function __construct(
18+
protected PostInterface $postRepository
19+
) {
20+
}
21+
22+
/**
23+
* Get previous post based on content relevance
24+
*
25+
* @param int|string $postId
26+
* @return object|null
27+
*/
28+
public function getPreviousPost(int|string $postId): ?object
29+
{
30+
return $this->getRelatedPostByRelevance($postId, 'previous');
31+
}
32+
33+
/**
34+
* Get next post based on content relevance
35+
*
36+
* @param int|string $postId
37+
* @return object|null
38+
*/
39+
public function getNextPost(int|string $postId): ?object
40+
{
41+
return $this->getRelatedPostByRelevance($postId, 'next');
42+
}
43+
44+
/**
45+
* Get both previous and next posts
46+
*
47+
* @param int|string $postId
48+
* @return array
49+
*/
50+
public function getNavigatePosts(int|string $postId): array
51+
{
52+
return [
53+
'previous' => $this->getPreviousPost($postId),
54+
'next' => $this->getNextPost($postId),
55+
];
56+
}
57+
58+
/**
59+
* Get posts with relevance score based on shared categories and tags
60+
* Navigation is purely content-based, not time-based
61+
*
62+
* @param int|string $postId
63+
* @param string $direction 'previous' or 'next'
64+
* @return object|null
65+
*/
66+
protected function getRelatedPostByRelevance(int|string $postId, string $direction = 'previous'): ?object
67+
{
68+
$currentPost = $this->postRepository->findById($postId);
69+
70+
if (!$currentPost) {
71+
return null;
72+
}
73+
74+
// Load current post's categories and tags
75+
$currentPost->load(['categories', 'tags']);
76+
$categoryIds = $currentPost->categories->pluck('id')->toArray();
77+
$tagIds = $currentPost->tags->pluck('id')->toArray();
78+
79+
if (empty($categoryIds) && empty($tagIds)) {
80+
// No categories or tags, return null (no navigation)
81+
return null;
82+
}
83+
84+
// Get all published posts except current one
85+
$posts = $this->postRepository->getModel()
86+
->wherePublished()
87+
->where('id', '!=', $postId)
88+
->with(['slugable', 'categories', 'tags', 'author'])
89+
->get();
90+
91+
if ($posts->isEmpty()) {
92+
return null;
93+
}
94+
95+
// Calculate relevance score for each post
96+
$scoredPosts = $posts->map(function ($post) use ($categoryIds, $tagIds) {
97+
$post->load(['categories', 'tags']);
98+
99+
$postCategoryIds = $post->categories->pluck('id')->toArray();
100+
$postTagIds = $post->tags->pluck('id')->toArray();
101+
102+
// Calculate shared categories and tags
103+
$sharedCategories = count(array_intersect($categoryIds, $postCategoryIds));
104+
$sharedTags = count(array_intersect($tagIds, $postTagIds));
105+
106+
// Weight categories higher than tags
107+
$relevanceScore = ($sharedCategories * 3) + ($sharedTags * 1);
108+
109+
$post->relevance_score = $relevanceScore;
110+
111+
return $post;
112+
});
113+
114+
// Filter posts with relevance score > 0
115+
$relevantPosts = $scoredPosts
116+
->filter(function ($post) {
117+
return $post->relevance_score > 0;
118+
})
119+
->sortByDesc('relevance_score')
120+
->values(); // Reset array keys
121+
122+
if ($relevantPosts->isEmpty()) {
123+
return null;
124+
}
125+
126+
// Group posts by relevance score
127+
$groupedByScore = $relevantPosts->groupBy('relevance_score');
128+
$scores = $groupedByScore->keys()->sortDesc();
129+
130+
if ($direction === 'previous') {
131+
// Get highest scoring post(s), pick first one
132+
$highestScorePosts = $groupedByScore->get($scores->first());
133+
return $highestScorePosts->first();
134+
} else {
135+
// For 'next', try to get a different post
136+
if ($scores->count() > 1) {
137+
// If we have multiple score levels, get from second highest
138+
$secondHighestPosts = $groupedByScore->get($scores->get(1));
139+
return $secondHighestPosts->first();
140+
} else {
141+
// If all posts have same score, get second post if available
142+
$highestScorePosts = $groupedByScore->get($scores->first());
143+
return $highestScorePosts->count() > 1 ? $highestScorePosts->get(1) : $highestScorePosts->first();
144+
}
145+
}
146+
}
147+
}

0 commit comments

Comments
 (0)