diff --git a/app/Http/Controllers/Guest/ArticleController.php b/app/Http/Controllers/Guest/ArticleController.php index 46f39b9..80ac83e 100644 --- a/app/Http/Controllers/Guest/ArticleController.php +++ b/app/Http/Controllers/Guest/ArticleController.php @@ -2,30 +2,20 @@ namespace App\Http\Controllers\Guest; -use App\Enums\ArticleStatus; -use App\Enums\CategoryStatus; use App\Http\Controllers\Controller; use App\Http\Requests\Guest\SearchArticlesRequest; use App\Http\Requests\Guest\ShowArticleRequest; use App\Jobs\IncrementArticleViewJob; -use App\Models\Article; -use App\Models\Category; -use App\Models\Tag; -use App\Models\Trending; -use App\Services\ArticleFilterCacheService; +use App\Services\GuestArticleService; use App\Services\SeoService; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\View\View; class ArticleController extends Controller { - /** - * Create a new guest article controller instance. - */ public function __construct( + private readonly GuestArticleService $guestArticleService, private readonly SeoService $seoService, - private readonly ArticleFilterCacheService $articleFilterCacheService ) { } @@ -42,7 +32,7 @@ public function index(SearchArticlesRequest $request): View ]; $isSearching = $filters['keyword'] !== ''; - $data = $this->buildListingData($filters, !$isSearching); + $data = $this->guestArticleService->getListingData($filters, !$isSearching); if ($isSearching) { $data['searchKeyword'] = $filters['keyword']; @@ -58,48 +48,12 @@ public function index(SearchArticlesRequest $request): View */ public function show(ShowArticleRequest $request, string $article): View { - $article = Article::query() - ->with(['category', 'seo', 'media', 'versions.media', 'author', 'tags']) - ->withCount('comments') - ->where('slug', $article) - ->where('status', ArticleStatus::PUBLISHED->value) - ->firstOrFail(); - - $tagIds = $article->tags->pluck('id')->all(); - - $relatedArticles = Article::query() - ->with(['category', 'media']) - ->where('status', ArticleStatus::PUBLISHED->value) - ->whereKeyNot($article->id) - ->when( - $tagIds !== [], - fn (Builder $query) => $query->whereHas('tags', fn (Builder $tagQuery) => $tagQuery->whereIn('tags.id', $tagIds)), - fn (Builder $query) => $query->where('category_id', $article->category_id) - ) - ->latest('published_at') - ->limit(3) - ->get(); + $data = $this->guestArticleService->getShowData($article); - $trendingArticles = Trending::query() - ->with(['article' => fn ($query) => $query->with('category')]) - ->latest('calculated_at') - ->orderBy('rank') - ->limit(4) - ->get() - ->pluck('article') - ->filter(fn ($item) => $item && $item->id !== $article->id) - ->values(); + IncrementArticleViewJob::dispatch($data['article']->id); + $this->seoService->applyForArticle($data['article']); - $trendingTags = Tag::query() - ->withCount('articles') - ->orderByDesc('articles_count') - ->limit(6) - ->get(['id', 'name', 'slug']); - - IncrementArticleViewJob::dispatch($article->id); - $this->seoService->applyForArticle($article); - - return view('guest.articles.show', compact('article', 'relatedArticles', 'trendingArticles', 'trendingTags')); + return view('guest.articles.show', $data); } /** @@ -107,20 +61,13 @@ public function show(ShowArticleRequest $request, string $article): View */ public function byCategory(Request $request, string $slug): View { - $category = Category::query() - ->where('slug', $slug) - ->where('status', CategoryStatus::ACTIVE->value) - ->firstOrFail(); - $filters = [ 'keyword' => (string) $request->query('keyword', ''), 'tag' => (string) $request->query('tag', ''), - 'category' => $slug, 'sort' => (string) $request->query('sort', 'latest'), ]; - $data = $this->buildListingData($filters); - $data['pageTitle'] = 'Danh mục: '.$category->name; + $data = $this->guestArticleService->getCategoryListingData($slug, $filters); return view('guest.articles.index', $data); } @@ -130,143 +77,14 @@ public function byCategory(Request $request, string $slug): View */ public function byTag(Request $request, string $slug): View { - $tag = Tag::query()->where('slug', $slug)->firstOrFail(); - $filters = [ 'keyword' => (string) $request->query('keyword', ''), - 'tag' => $slug, 'category' => (string) $request->query('category', ''), 'sort' => (string) $request->query('sort', 'latest'), ]; - $data = $this->buildListingData($filters); - $data['pageTitle'] = 'Chủ đề: '.$tag->name; + $data = $this->guestArticleService->getTagListingData($slug, $filters); return view('guest.articles.index', $data); } - - private function buildListingData(array $filters, bool $useFeaturedArticle = true): array - { - $normalizedFilters = [ - 'keyword' => (string) ($filters['keyword'] ?? ''), - 'tag' => (string) ($filters['tag'] ?? ''), - 'category' => (string) ($filters['category'] ?? ''), - 'sort' => in_array((string) ($filters['sort'] ?? 'latest'), ['latest', 'popular', 'featured'], true) - ? (string) $filters['sort'] - : 'latest', - ]; - - $baseQuery = Article::query() - ->with(['category', 'tags', 'media']) - ->withCount('comments') - ->withSum('analytics as total_views', 'views') - ->where('status', ArticleStatus::PUBLISHED->value); - - $this->applySearchFilters($baseQuery, $normalizedFilters); - $this->applySort($baseQuery, $normalizedFilters['sort']); - - $featuredArticle = null; - - if ($useFeaturedArticle) { - $featuredArticle = (clone $baseQuery) - ->latest('published_at') - ->first(); - } - - $articles = (clone $baseQuery) - ->when($useFeaturedArticle && $featuredArticle, fn (Builder $query) => $query->whereKeyNot($featuredArticle->id)) - ->latest('published_at') - ->paginate(10) - ->withQueryString(); - - $popularArticles = Trending::query() - ->with(['article' => fn ($query) => $query->with('category')]) - ->latest('calculated_at') - ->orderBy('rank') - ->limit(5) - ->get() - ->pluck('article') - ->filter() - ->values(); - - if ($popularArticles->isEmpty()) { - $popularArticles = Article::query() - ->with('category') - ->where('status', ArticleStatus::PUBLISHED->value) - ->latest('published_at') - ->limit(5) - ->get(); - } - - $popularTags = Tag::query() - ->withCount('articles') - ->orderByDesc('articles_count') - ->limit(10) - ->get(['id', 'name', 'slug']); - - $categories = $this->articleFilterCacheService->getCategories(); - $tags = $this->articleFilterCacheService->getTags(); - $relatedCategories = Category::query() - ->where('status', CategoryStatus::ACTIVE->value) - ->withCount([ - 'articles' => fn (Builder $query) => $query->where('status', ArticleStatus::PUBLISHED->value), - ]) - ->orderByDesc('articles_count') - ->limit(4) - ->get(['id', 'name', 'slug']); - - return [ - 'articles' => $articles, - 'featuredArticle' => $featuredArticle, - 'popularArticles' => $popularArticles, - 'popularTags' => $popularTags, - 'categories' => $categories, - 'relatedCategories' => $relatedCategories, - 'tags' => $tags, - 'filters' => $normalizedFilters, - 'pageTitle' => 'Tin tức mới nhất', - ]; - } - - private function applySearchFilters(Builder $query, array $filters): void - { - $query - ->when( - !empty($filters['keyword'] ?? null), - fn (Builder $builder) => $builder->where(function (Builder $innerQuery) use ($filters): void { - $keyword = (string) $filters['keyword']; - $innerQuery - ->where('title', 'like', "%{$keyword}%") - ->orWhere('excerpt', 'like', "%{$keyword}%") - ->orWhere('content', 'like', "%{$keyword}%"); - }) - ) - ->when( - !empty($filters['tag'] ?? null), - fn (Builder $builder) => $builder->whereHas('tags', function (Builder $tagQuery) use ($filters): void { - $tagQuery->where('slug', $filters['tag']); - }) - ) - ->when( - !empty($filters['category'] ?? null), - fn (Builder $builder) => $builder->whereHas('category', function (Builder $categoryQuery) use ($filters): void { - $categoryQuery->where('slug', $filters['category']); - }) - ); - } - - private function applySort(Builder $query, string $sort): void - { - match ($sort) { - 'popular' => $query - ->orderByDesc('total_views') - ->latest('published_at'), - 'featured' => $query - ->orderByDesc('comments_count') - ->orderByDesc('total_views') - ->latest('published_at'), - default => $query->latest('published_at'), - }; - } - } diff --git a/app/Repositories/ArticleRepository.php b/app/Repositories/ArticleRepository.php index 924a7c6..80c39ea 100644 --- a/app/Repositories/ArticleRepository.php +++ b/app/Repositories/ArticleRepository.php @@ -2,11 +2,17 @@ namespace App\Repositories; +use App\Enums\ArticleStatus; use App\Models\Article; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; class ArticleRepository { + /** + * Paginate articles ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 10): LengthAwarePaginator { return Article::query() @@ -15,11 +21,17 @@ public function paginateLatest(int $perPage = 10): LengthAwarePaginator ->paginate($perPage); } + /** + * Create a new article record with the given attributes. + */ public function create(array $data): Article { return Article::query()->create($data); } + /** + * Update an existing article with the given attributes and return the refreshed instance. + */ public function update(Article $article, array $data): Article { $article->update($data); @@ -27,8 +39,103 @@ public function update(Article $article, array $data): Article return $article->refresh(); } + /** + * Delete the given article from the database. + */ public function delete(Article $article): void { $article->delete(); } + + // ── Guest read methods ───────────────────────────────────────────────────── + + /** + * Find a published article by its slug or throw a 404 exception. + * Eager-loads category, SEO, media, versions, author, and tags. + */ + public function findPublishedBySlug(string $slug): Article + { + return Article::query() + ->with(['category', 'seo', 'media', 'versions.media', 'author', 'tags']) + ->withCount('comments') + ->where('slug', $slug) + ->where('status', ArticleStatus::PUBLISHED->value) + ->firstOrFail(); + } + + /** + * Retrieve articles related to the given article by shared tags. + * Falls back to same category when the article has no tags. + * + * @param int $limit Maximum number of articles to return. + */ + public function getRelatedArticles(Article $article, int $limit = 3): Collection + { + $tagIds = $article->tags->pluck('id')->all(); + + return Article::query() + ->with(['category', 'media']) + ->where('status', ArticleStatus::PUBLISHED->value) + ->whereKeyNot($article->id) + ->when( + $tagIds !== [], + fn (Builder $query) => $query->whereHas('tags', fn (Builder $tagQuery) => $tagQuery->whereIn('tags.id', $tagIds)), + fn (Builder $query) => $query->where('category_id', $article->category_id) + ) + ->latest('published_at') + ->limit($limit) + ->get(); + } + + /** + * Build a base Eloquent query for published articles applying keyword, tag, + * category, and sort filters. The returned Builder can be further decorated + * (e.g. paginate, limit) by the caller. + * + * Supported filter keys: + * - keyword (string) Full-text search across title, excerpt, content. + * - tag (string) Filter by tag slug. + * - category (string) Filter by category slug. + * - sort (string) One of: latest | popular | featured. + */ + public function buildPublishedFilteredQuery(array $filters): Builder + { + $query = Article::query() + ->with(['category', 'tags', 'media']) + ->withCount('comments') + ->withSum('analytics as total_views', 'views') + ->where('status', ArticleStatus::PUBLISHED->value); + + $query + ->when( + !empty($filters['keyword'] ?? null), + fn (Builder $builder) => $builder->where(function (Builder $innerQuery) use ($filters): void { + $keyword = (string) $filters['keyword']; + $innerQuery + ->where('title', 'like', "%{$keyword}%") + ->orWhere('excerpt', 'like', "%{$keyword}%") + ->orWhere('content', 'like', "%{$keyword}%"); + }) + ) + ->when( + !empty($filters['tag'] ?? null), + fn (Builder $builder) => $builder->whereHas('tags', function (Builder $tagQuery) use ($filters): void { + $tagQuery->where('slug', $filters['tag']); + }) + ) + ->when( + !empty($filters['category'] ?? null), + fn (Builder $builder) => $builder->whereHas('category', function (Builder $categoryQuery) use ($filters): void { + $categoryQuery->where('slug', $filters['category']); + }) + ); + + match ($filters['sort'] ?? 'latest') { + 'popular' => $query->orderByDesc('total_views')->latest('published_at'), + 'featured' => $query->orderByDesc('comments_count')->orderByDesc('total_views')->latest('published_at'), + default => $query->latest('published_at'), + }; + + return $query; + } } diff --git a/app/Repositories/CategoryRepository.php b/app/Repositories/CategoryRepository.php index 39e1573..cd771ae 100644 --- a/app/Repositories/CategoryRepository.php +++ b/app/Repositories/CategoryRepository.php @@ -2,11 +2,18 @@ namespace App\Repositories; +use App\Enums\ArticleStatus; +use App\Enums\CategoryStatus; use App\Models\Category; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; class CategoryRepository { + /** + * Paginate categories ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 15): LengthAwarePaginator { return Category::query() @@ -14,11 +21,17 @@ public function paginateLatest(int $perPage = 15): LengthAwarePaginator ->paginate($perPage); } + /** + * Create a new category record with the given attributes. + */ public function create(array $data): Category { return Category::query()->create($data); } + /** + * Update an existing category with the given attributes and return the refreshed instance. + */ public function update(Category $category, array $data): Category { $category->update($data); @@ -26,8 +39,40 @@ public function update(Category $category, array $data): Category return $category->refresh(); } + /** + * Delete the given category from the database. + */ public function delete(Category $category): void { $category->delete(); } + + /** + * Find an active category by its slug or throw a 404 exception. + */ + public function findActiveBySlugOrFail(string $slug): Category + { + return Category::query() + ->where('slug', $slug) + ->where('status', CategoryStatus::ACTIVE->value) + ->firstOrFail(); + } + + /** + * Retrieve active categories with their published article count, + * ordered by article count descending. + * + * @param int $limit Maximum number of categories to return. + */ + public function getActiveWithPublishedArticleCount(int $limit = 4): Collection + { + return Category::query() + ->where('status', CategoryStatus::ACTIVE->value) + ->withCount([ + 'articles' => fn (Builder $query) => $query->where('status', ArticleStatus::PUBLISHED->value), + ]) + ->orderByDesc('articles_count') + ->limit($limit) + ->get(['id', 'name', 'slug']); + } } diff --git a/app/Repositories/CommentRepository.php b/app/Repositories/CommentRepository.php index ccaa482..b1619ba 100644 --- a/app/Repositories/CommentRepository.php +++ b/app/Repositories/CommentRepository.php @@ -8,6 +8,9 @@ class CommentRepository { + /** + * Paginate comments for the given article, newest first, with the author eager-loaded. + */ public function paginateByArticle(Article $article, int $perPage = 20): LengthAwarePaginator { return Comment::query() @@ -17,11 +20,17 @@ public function paginateByArticle(Article $article, int $perPage = 20): LengthAw ->paginate($perPage); } + /** + * Create a new comment record with the given attributes. + */ public function create(array $data): Comment { return Comment::query()->create($data); } + /** + * Update an existing comment with the given attributes and return the refreshed instance. + */ public function update(Comment $comment, array $data): Comment { $comment->update($data); @@ -29,6 +38,9 @@ public function update(Comment $comment, array $data): Comment return $comment->refresh(); } + /** + * Delete the given comment from the database. + */ public function delete(Comment $comment): void { $comment->delete(); diff --git a/app/Repositories/ContactSubmissionRepository.php b/app/Repositories/ContactSubmissionRepository.php index 93934d2..f0e6330 100644 --- a/app/Repositories/ContactSubmissionRepository.php +++ b/app/Repositories/ContactSubmissionRepository.php @@ -6,6 +6,15 @@ class ContactSubmissionRepository { + /** + * Find an existing submission from the same day that shares any of: + * IP address, email, or phone — used to detect duplicate submissions. + * + * @param string $submittedDate Date string (Y-m-d) of the submission. + * @param string $ipAddress Client IP address. + * @param string $email Submitted email address. + * @param string $phone Submitted phone number. + */ public function findDailyDuplicate(string $submittedDate, string $ipAddress, string $email, string $phone): ?ContactSubmission { return ContactSubmission::query() @@ -19,6 +28,9 @@ public function findDailyDuplicate(string $submittedDate, string $ipAddress, str ->first(); } + /** + * Create a new contact submission record with the given attributes. + */ public function create(array $data): ContactSubmission { return ContactSubmission::query()->create($data); diff --git a/app/Repositories/TagRepository.php b/app/Repositories/TagRepository.php index b10cbb2..a1b8ba0 100644 --- a/app/Repositories/TagRepository.php +++ b/app/Repositories/TagRepository.php @@ -4,9 +4,13 @@ use App\Models\Tag; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; class TagRepository { + /** + * Paginate tags ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 15): LengthAwarePaginator { return Tag::query() @@ -14,11 +18,17 @@ public function paginateLatest(int $perPage = 15): LengthAwarePaginator ->paginate($perPage); } + /** + * Create a new tag record with the given attributes. + */ public function create(array $data): Tag { return Tag::query()->create($data); } + /** + * Update an existing tag with the given attributes and return the refreshed instance. + */ public function update(Tag $tag, array $data): Tag { $tag->update($data); @@ -26,8 +36,34 @@ public function update(Tag $tag, array $data): Tag return $tag->refresh(); } + /** + * Delete the given tag from the database. + */ public function delete(Tag $tag): void { $tag->delete(); } + + /** + * Find a tag by its slug or throw a 404 exception. + */ + public function findBySlugOrFail(string $slug): Tag + { + return Tag::query()->where('slug', $slug)->firstOrFail(); + } + + /** + * Retrieve the most-used tags ordered by article count descending. + * + * @param int $limit Maximum number of tags to return. + * @param array $columns Columns to select. + */ + public function getPopularByArticleCount(int $limit = 10, array $columns = ['id', 'name', 'slug']): Collection + { + return Tag::query() + ->withCount('articles') + ->orderByDesc('articles_count') + ->limit($limit) + ->get($columns); + } } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index b6a3358..cc3eb28 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -23,11 +23,18 @@ public function __construct( ) { } + /** + * Paginate articles ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 10): LengthAwarePaginator { return $this->articleRepository->paginateLatest($perPage); } + /** + * Create a new article with its SEO record, media uploads, and an initial + * version snapshot. Clears the response cache after persisting. + */ public function create(array $data): Article { $seoData = $this->extractSeoData($data); @@ -42,6 +49,11 @@ public function create(array $data): Article return $article; } + /** + * Update an existing article. A new version snapshot is created only when + * the content changes or new version files are provided. + * Clears the response cache after persisting. + */ public function update(Article $article, array $data): Article { $seoData = $this->extractSeoData($data); @@ -62,6 +74,11 @@ public function update(Article $article, array $data): Article return $article; } + /** + * Update the article slug, normalising it and appending a counter to ensure + * uniqueness. Throws InvalidArgumentException for empty slugs. + * Clears the response cache after persisting. + */ public function updateSlug(Article $article, string $slug): Article { $normalizedSlug = Str::slug($slug); @@ -81,6 +98,10 @@ public function updateSlug(Article $article, string $slug): Article return $updated; } + /** + * Transition a draft article to the "review" workflow status. + * Throws InvalidArgumentException if the article is not a draft. + */ public function submitForReview(Article $article): Article { if ($article->status !== ArticleStatus::DRAFT) { @@ -97,6 +118,12 @@ public function submitForReview(Article $article): Article return $updated; } + /** + * Approve an article that is currently in the "review" workflow status. + * Throws InvalidArgumentException if the article is not in review. + * + * @param int|null $reviewerId ID of the user performing the approval. + */ public function approve(Article $article, ?int $reviewerId): Article { if ($article->workflow_status !== 'review') { @@ -114,6 +141,13 @@ public function approve(Article $article, ?int $reviewerId): Article return $updated; } + /** + * Publish an approved article, set its published_at timestamp, and dispatch + * the publish pipeline job. Throws InvalidArgumentException if the article + * is not in "approved" or "published" workflow status. + * + * @param int|null $publisherId ID of the user performing the publish action. + */ public function publish(Article $article, ?int $publisherId): Article { if (!in_array($article->workflow_status, ['approved', 'published'], true)) { @@ -133,12 +167,19 @@ public function publish(Article $article, ?int $publisherId): Article return $updated; } + /** + * Delete the given article and clear the response cache. + */ public function delete(Article $article): void { $this->articleRepository->delete($article); $this->clearResponseCache(); } + /** + * Restore a set of allowed fields from an activity log snapshot ("old" properties). + * Throws InvalidArgumentException when the activity contains no restorable data. + */ public function restoreFromActivity(Article $article, Activity $activity): Article { $oldValues = $activity->properties['old'] ?? []; @@ -168,6 +209,10 @@ public function restoreFromActivity(Article $article, Activity $activity): Artic return $updated; } + /** + * Generate a unique slug derived from $baseSlug, skipping the given article ID. + * Appends an incrementing integer suffix until the slug is unique. + */ private function resolveUniqueSlug(string $baseSlug, int $ignoreId): string { $slug = $baseSlug; @@ -185,6 +230,10 @@ private function resolveUniqueSlug(string $baseSlug, int $ignoreId): string return $slug; } + /** + * Pop SEO-related keys (seo_title, seo_description, seo_keywords, seo_og_image) + * from the data array and return them as a separate array. + */ private function extractSeoData(array &$data): array { $seoData = [ @@ -199,6 +248,10 @@ private function extractSeoData(array &$data): array return $seoData; } + /** + * Upsert the ArticleSeo record for the article and run the SEO analyzer to + * update the score, breakdown, and warnings. + */ private function syncSeo(Article $article, array $seoData): void { $articleSeo = $article->seo()->updateOrCreate([], $seoData); @@ -215,6 +268,10 @@ private function syncSeo(Article $article, array $seoData): void ]); } + /** + * Pop media-related keys (featured_image, gallery, attachments, version_files) + * from the data array and return them as a separate array. + */ private function extractMediaData(array &$data): array { $mediaData = [ @@ -229,6 +286,11 @@ private function extractMediaData(array &$data): array return $mediaData; } + /** + * Persist featured image, gallery, and attachment files to their respective + * Spatie Media Library collections. Replaces the featured image if a new one + * is provided; appends gallery and attachment files. + */ private function syncArticleMedia(Article $article, array $mediaData): void { if ($mediaData['featured_image'] instanceof UploadedFile) { @@ -249,6 +311,12 @@ private function syncArticleMedia(Article $article, array $mediaData): void } } + /** + * Create a versioned snapshot of the current article content with an + * auto-incremented version number, attaching any provided version files. + * + * @param mixed $updatedBy User ID of the editor (cast to int) or null. + */ private function createVersionSnapshot(Article $article, mixed $updatedBy, array $versionFiles): ArticleVersion { $nextVersionNumber = (int) $article->versions()->max('version_number') + 1; @@ -268,6 +336,9 @@ private function createVersionSnapshot(Article $article, mixed $updatedBy, array return $version; } + /** + * Flush the entire Spatie ResponseCache to ensure stale pages are not served. + */ private function clearResponseCache(): void { ResponseCache::clear(); diff --git a/app/Services/CategoryService.php b/app/Services/CategoryService.php index 931126e..7416c11 100644 --- a/app/Services/CategoryService.php +++ b/app/Services/CategoryService.php @@ -14,11 +14,17 @@ public function __construct( ) { } + /** + * Paginate categories ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 15): LengthAwarePaginator { return $this->categoryRepository->paginateLatest($perPage); } + /** + * Create a new category with the given name, description, and status. + */ public function create(array $data): Category { return $this->categoryRepository->create([ @@ -28,6 +34,9 @@ public function create(array $data): Category ]); } + /** + * Update an existing category with the given name, description, and status. + */ public function update(Category $category, array $data): Category { return $this->categoryRepository->update($category, [ @@ -37,6 +46,9 @@ public function update(Category $category, array $data): Category ]); } + /** + * Delete the given category from the database. + */ public function delete(Category $category): void { $this->categoryRepository->delete($category); diff --git a/app/Services/CommentService.php b/app/Services/CommentService.php index 99cb841..a5e827f 100644 --- a/app/Services/CommentService.php +++ b/app/Services/CommentService.php @@ -15,11 +15,17 @@ public function __construct( ) { } + /** + * Paginate comments for the given article, newest first, with the author eager-loaded. + */ public function paginateByArticle(Article $article, int $perPage = 20): LengthAwarePaginator { return $this->commentRepository->paginateByArticle($article, $perPage); } + /** + * Create a comment associated with the given article. + */ public function createForArticle(Article $article, array $data): Comment { return $this->commentRepository->create([ @@ -32,6 +38,10 @@ public function createForArticle(Article $article, array $data): Comment ]); } + /** + * Update a comment that belongs to the given article. + * Throws InvalidArgumentException if the comment does not belong to the article. + */ public function updateForArticle(Article $article, Comment $comment, array $data): Comment { $this->ensureCommentBelongsToArticle($article, $comment); @@ -45,12 +55,19 @@ public function updateForArticle(Article $article, Comment $comment, array $data ]); } + /** + * Delete a comment that belongs to the given article. + * Throws InvalidArgumentException if the comment does not belong to the article. + */ public function deleteForArticle(Article $article, Comment $comment): void { $this->ensureCommentBelongsToArticle($article, $comment); $this->commentRepository->delete($comment); } + /** + * Assert that the comment belongs to the article; throws InvalidArgumentException otherwise. + */ private function ensureCommentBelongsToArticle(Article $article, Comment $comment): void { if ((int) $comment->article_id !== (int) $article->id) { diff --git a/app/Services/GuestArticleService.php b/app/Services/GuestArticleService.php new file mode 100644 index 0000000..e7254fb --- /dev/null +++ b/app/Services/GuestArticleService.php @@ -0,0 +1,162 @@ +articleRepository->findPublishedBySlug($slug); + $relatedArticles = $this->articleRepository->getRelatedArticles($article); + $trendingArticles = $this->getTrendingArticles($article->id); + $trendingTags = $this->tagRepository->getPopularByArticleCount(6); + + return compact('article', 'relatedArticles', 'trendingArticles', 'trendingTags'); + } + + /** + * Fetch all data needed for the article listing / search page. + */ + public function getListingData(array $filters, bool $useFeaturedArticle = true): array + { + $normalized = $this->normalizeFilters($filters); + $baseQuery = $this->articleRepository->buildPublishedFilteredQuery($normalized); + + $featuredArticle = null; + + if ($useFeaturedArticle) { + $featuredArticle = (clone $baseQuery)->latest('published_at')->first(); + } + + $articles = (clone $baseQuery) + ->when( + $useFeaturedArticle && $featuredArticle, + fn ($query) => $query->whereKeyNot($featuredArticle->id) + ) + ->paginate(10) + ->withQueryString(); + + $popularArticles = $this->getPopularArticles(); + $popularTags = $this->tagRepository->getPopularByArticleCount(10); + $categories = $this->articleFilterCacheService->getCategories(); + $tags = $this->articleFilterCacheService->getTags(); + $relatedCategories = $this->categoryRepository->getActiveWithPublishedArticleCount(); + + return [ + 'articles' => $articles, + 'featuredArticle' => $featuredArticle, + 'popularArticles' => $popularArticles, + 'popularTags' => $popularTags, + 'categories' => $categories, + 'relatedCategories' => $relatedCategories, + 'tags' => $tags, + 'filters' => $normalized, + 'pageTitle' => 'Tin tức mới nhất', + ]; + } + + /** + * Fetch listing data scoped to a category slug. + */ + public function getCategoryListingData(string $slug, array $filters): array + { + $category = $this->categoryRepository->findActiveBySlugOrFail($slug); + + $data = $this->getListingData(array_merge($filters, ['category' => $slug])); + $data['pageTitle'] = 'Danh mục: ' . $category->name; + + return $data; + } + + /** + * Fetch listing data scoped to a tag slug. + */ + public function getTagListingData(string $slug, array $filters): array + { + $tag = $this->tagRepository->findBySlugOrFail($slug); + + $data = $this->getListingData(array_merge($filters, ['tag' => $slug])); + $data['pageTitle'] = 'Chủ đề: ' . $tag->name; + + return $data; + } + + /** + * Validate and normalize filter inputs. + */ + public function normalizeFilters(array $filters): array + { + return [ + 'keyword' => (string) ($filters['keyword'] ?? ''), + 'tag' => (string) ($filters['tag'] ?? ''), + 'category' => (string) ($filters['category'] ?? ''), + 'sort' => in_array((string) ($filters['sort'] ?? 'latest'), ['latest', 'popular', 'featured'], true) + ? (string) $filters['sort'] + : 'latest', + ]; + } + + /** + * Fetch the latest hot-score trending articles, excluding the given article ID. + */ + private function getTrendingArticles(int $excludeArticleId): Collection + { + return Trending::query() + ->with(['article' => fn ($query) => $query->with('category')]) + ->latest('calculated_at') + ->orderBy('rank') + ->limit(4) + ->get() + ->pluck('article') + ->filter(fn ($item) => $item && $item->id !== $excludeArticleId) + ->values(); + } + + /** + * Fetch the most popular articles from Trending data. + * Falls back to the five most recently published articles when no trending + * records exist. + */ + private function getPopularArticles(): Collection + { + $popular = Trending::query() + ->with(['article' => fn ($query) => $query->with('category')]) + ->latest('calculated_at') + ->orderBy('rank') + ->limit(5) + ->get() + ->pluck('article') + ->filter() + ->values(); + + if ($popular->isEmpty()) { + return Article::query() + ->with('category') + ->where('status', ArticleStatus::PUBLISHED->value) + ->latest('published_at') + ->limit(5) + ->get(); + } + + return $popular; + } +} diff --git a/app/Services/HotArticleService.php b/app/Services/HotArticleService.php index 1103771..1ca0b9c 100644 --- a/app/Services/HotArticleService.php +++ b/app/Services/HotArticleService.php @@ -176,11 +176,19 @@ public function calculateHotScores(?CarbonInterface $trackedDate = null): int return count($scoredRows); } + /** + * Build the Redis key used to store the view counter for an article. + */ private function viewCounterKey(int $articleId): string { return sprintf('hot:article:%d:views', $articleId); } + /** + * Return a recency bonus score based on how many hours have elapsed since + * the article was published at the time of score calculation. + * Score: 20 (≤24 h) · 10 (≤72 h) · 0 (older). + */ private function freshScore(Article $article, CarbonInterface $calculatedAt): int { $publishedAt = $article->published_at; diff --git a/app/Services/SeoAnalyzerService.php b/app/Services/SeoAnalyzerService.php index 6a550ee..66d4bcf 100644 --- a/app/Services/SeoAnalyzerService.php +++ b/app/Services/SeoAnalyzerService.php @@ -44,6 +44,9 @@ public function analyze(Article $article): array ]; } + /** + * Score the title length. Returns 10 for 50–60 chars, 5 for 40–70 chars, 0 otherwise. + */ private function scoreTitleLength(string $title): int { $len = Str::length(trim($title)); @@ -59,6 +62,9 @@ private function scoreTitleLength(string $title): int return 0; } + /** + * Score the meta description length. Returns 15 for 120–160 chars, 8 for 90–180 chars, 0 otherwise. + */ private function scoreMetaDescription(string $metaDescription): int { $len = Str::length(trim($metaDescription)); @@ -74,6 +80,9 @@ private function scoreMetaDescription(string $metaDescription): int return 0; } + /** + * Return $max when the primary keyword appears in $text (case-insensitive), 0 otherwise. + */ private function scoreKeywordInText(string $keyword, string $text, int $max): int { if ($keyword === '') { @@ -83,6 +92,10 @@ private function scoreKeywordInText(string $keyword, string $text, int $max): in return Str::contains(Str::lower($text), Str::lower($keyword)) ? $max : 0; } + /** + * Score keyword density in plain-text content. + * Returns 10 for 1–2 %, 5 for 0.5–3 %, 0 otherwise. + */ private function scoreKeywordDensity(string $keyword, string $contentText): int { if ($keyword === '') { @@ -110,6 +123,10 @@ private function scoreKeywordDensity(string $keyword, string $contentText): int return 0; } + /** + * Score content length in words. + * Returns 10 for ≥800 words, 5 for ≥500 words, 0 otherwise. + */ private function scoreContentLength(string $contentText): int { $wordCount = str_word_count(strip_tags($contentText)); @@ -125,6 +142,11 @@ private function scoreContentLength(string $contentText): int return 0; } + /** + * Score the heading structure of the HTML content. + * Full marks (10) require: exactly one H1 containing the keyword, 2–3 H2s, and 3–5 H3s. + * Partial marks (5) require at least one H1 and one H2. + */ private function scoreHeadingStructure(string $contentHtml, string $keyword): int { $h1Count = preg_match_all('/
- {!! nl2br(e((string) \Illuminate\Support\Str::limit(strip_tags($article->content), 2200, '...'))) !!} -
- -- Nội dung được biên tập theo hướng cân bằng giữa tốc độ cập nhật và độ chính xác thông tin, - đồng thời đặt trọng tâm vào bối cảnh, dữ liệu và tác động dài hạn đối với xã hội. -
- -- "Báo chí chất lượng không chỉ cung cấp thông tin mà còn giúp độc giả hiểu đúng bản chất của sự kiện." -- -
- Chúng tôi ưu tiên tiêu chuẩn xác minh nhiều lớp, nguồn trích dẫn rõ ràng và quy trình biên tập độc lập, - nhằm giảm thiểu nhiễu thông tin và tăng giá trị tham khảo cho người đọc. -
+ {!! $article->content !!}{{ $featuredArticle->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($featuredArticle->content), 170) }}
+{{ $featuredArticle->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($featuredArticle->content), 170) }}
Đọc ngay arrow_forward @@ -52,10 +52,10 @@ class="article-lazy-image absolute inset-0 h-full w-full object-cover opacity-75 {{ $article->published_at?->diffForHumans() ?? 'Vừa xong' }}{{ $article->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($article->content), 170) }}
+ +{{ $article->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($article->content), 170) }}