feat(blog): add city_id to blogs for city-specific scoping

- Introduced a new nullable city_id column in the blogs table to allow scoping of blog posts to specific cities.
- Updated Blog entity to include a ManyToOne relationship with the City entity.
- Enhanced BlogController to handle city_id in the request, allowing filtering of posts by city.
- Modified BlogRepository to support querying published posts based on city_id.
- Added tests to ensure correct behavior for city-scoped and nationwide posts, including creation and updating of posts with city associations.
This commit is contained in:
hamed
2026-07-19 08:23:57 +03:30
parent bd66b213c2
commit a4b07c2f80
10 changed files with 535 additions and 178 deletions
+46 -5
View File
@@ -5,6 +5,7 @@ namespace App\Blog\Controller;
use App\Auth\Entity\User;
use App\Blog\Entity\Blog;
use App\Blog\Repository\BlogRepository;
use App\Location\Repository\CityRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
@@ -20,10 +21,31 @@ class BlogController extends BaseController
{
public function __construct(
private readonly BlogRepository $blogRepo,
private readonly CityRepository $cityRepo,
private readonly FileValidatorService $fileValidator,
private readonly string $projectDir,
) {}
/**
* city_id ورودی ادمین را به Entity تبدیل می‌کند.
* مقدار خالی/صفر/null یعنی «سراسری» و عمداً به null نگاشت می‌شود.
*
* @throws \App\Shared\Exception\AppException وقتی شناسهٔ شهر نامعتبر باشد
*/
private function resolveCity(mixed $cityId): ?\App\Location\Entity\City
{
if ($cityId === null || $cityId === '' || (int) $cityId === 0) {
return null;
}
$city = $this->cityRepo->find((int) $cityId);
if ($city === null) {
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'شهر یافت نشد', 422);
}
return $city;
}
// ── Public list/detail ────────────────────────────────────────────────────
#[OA\Get(
@@ -32,6 +54,13 @@ class BlogController extends BaseController
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20, maximum: 50)),
new OA\Parameter(
name: 'city_id',
in: 'query',
required: false,
description: 'Scope to one city: returns that city\'s posts plus nationwide posts (city_id IS NULL). Omit to return every published post.',
schema: new OA\Schema(type: 'integer')
),
],
responses: [
new OA\Response(
@@ -58,12 +87,18 @@ class BlogController extends BaseController
#[Route('/api/v1/blogs', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$tag = $request->query->get('tag') ?: null;
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$tag = $request->query->get('tag') ?: null;
$cityId = $request->query->get('city_id') !== null
? max(1, (int) $request->query->get('city_id'))
: null;
$blogs = array_map(fn(Blog $b) => $b->toListArray(), $this->blogRepo->findPublished($page, $limit, $tag));
$total = $this->blogRepo->countPublished($tag);
$blogs = array_map(
fn(Blog $b) => $b->toListArray(),
$this->blogRepo->findPublished($page, $limit, $tag, $cityId)
);
$total = $this->blogRepo->countPublished($tag, $cityId);
return $this->paginated($blogs, $total, $page, $limit);
}
@@ -127,6 +162,7 @@ class BlogController extends BaseController
new OA\Property(property: 'summary', type: 'string', nullable: true),
new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true),
new OA\Property(property: 'city_id', type: 'integer', nullable: true, description: 'City this post belongs to. Omit or null for a nationwide post.'),
]
)
),
@@ -190,6 +226,8 @@ class BlogController extends BaseController
if (!empty($data['tags'])) $blog->setTags((array)$data['tags']);
if (!empty($data['status'])) $blog->setStatus($data['status']);
if (!empty($data['image_url'])) $blog->setImageUrl($data['image_url']);
// نبودِ city_id یعنی سراسری — پس همیشه اعمال می‌شود، نه فقط وقتی مقدار دارد.
$blog->setCity($this->resolveCity($data['city_id'] ?? null));
// Ensure slug uniqueness
if ($this->blogRepo->findBySlug($blog->getSlug()) !== null) {
@@ -214,6 +252,7 @@ class BlogController extends BaseController
new OA\Property(property: 'summary', type: 'string', nullable: true),
new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true),
new OA\Property(property: 'city_id', type: 'integer', nullable: true, description: 'City this post belongs to. Omit or null for a nationwide post.'),
]
)
),
@@ -263,6 +302,8 @@ class BlogController extends BaseController
if (array_key_exists('tags', $data)) $blog->setTags((array)$data['tags']);
if (array_key_exists('status', $data)) $blog->setStatus($data['status']);
if (array_key_exists('image_url', $data)) $blog->setImageUrl($data['image_url'] ?: null);
// PATCH: فقط وقتی صریحاً فرستاده شد تغییر کند. ارسال null یعنی «سراسری‌اش کن».
if (array_key_exists('city_id', $data)) $blog->setCity($this->resolveCity($data['city_id']));
$this->blogRepo->save($blog);