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);
+27
View File
@@ -3,6 +3,7 @@
namespace App\Blog\Entity;
use App\Auth\Entity\User;
use App\Location\Entity\City;
use Doctrine\ORM\Mapping as ORM;
use App\Blog\Repository\BlogRepository;
use Symfony\Component\Uid\Uuid;
@@ -10,6 +11,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: BlogRepository::class)]
#[ORM\Table(name: 'blogs')]
#[ORM\Index(columns: ['status', 'created_at'], name: 'idx_blogs_status')]
#[ORM\Index(columns: ['city_id'], name: 'idx_blogs_city')]
class Blog
{
public const STATUS_DRAFT = 'draft';
@@ -46,6 +48,13 @@ class Blog
#[ORM\JoinColumn(name: 'author_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private User $author;
// شهر پست. NULL معنای دائمی دارد: «پست سراسری» که روی دامنهٔ اصلی canonical
// می‌شود. سایت عمومی چند-دامنه‌ای بر پایهٔ همین تفکیک تصمیم می‌گیرد پست را روی
// دامنهٔ شهر نشان دهد یا روی دامنهٔ اصلی.
#[ORM\ManyToOne(targetEntity: City::class)]
#[ORM\JoinColumn(name: 'city_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?City $city = null;
#[ORM\Column(type: 'json')]
private array $tags = [];
@@ -80,6 +89,7 @@ class Blog
public function getAuthor(): User { return $this->author; }
public function getTags(): array { return $this->tags; }
public function getStatus(): string { return $this->status; }
public function getCity(): ?City { return $this->city; }
public function setTitle(string $v): self { $this->title = $v; $this->touch(); return $this; }
public function setSlug(string $v): self { $this->slug = $v; $this->touch(); return $this; }
@@ -89,6 +99,8 @@ class Blog
public function setImagePath(?string $v): self { $this->imagePath = $v; $this->touch(); return $this; }
public function setTags(array $v): self { $this->tags = $v; $this->touch(); return $this; }
public function setStatus(string $v): self { $this->status = $v; $this->touch(); return $this; }
/** null = پست سراسری (روی همهٔ دامنه‌ها، canonical روی دامنهٔ اصلی) */
public function setCity(?City $v): self { $this->city = $v; $this->touch(); return $this; }
private function touch(): void { $this->updatedAt = time(); }
@@ -112,11 +124,25 @@ class Blog
'tags' => $this->tags,
'status' => $this->status,
'author' => $this->authorToArray(),
'city' => $this->cityToArray(),
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
/** null = پست سراسری. مصرف‌کننده روی همین null تصمیم canonical می‌گیرد. */
private function cityToArray(): ?array
{
if ($this->city === null) {
return null;
}
return [
'id' => (string) $this->city->getId(),
'name' => $this->city->getName(),
];
}
private function authorToArray(): ?array
{
if ($this->author === null) {
@@ -144,6 +170,7 @@ class Blog
'image_url' => $this->imageUrl,
'tags' => $this->tags,
'status' => $this->status,
'city' => $this->cityToArray(),
'created_at' => $this->createdAt,
];
}
+21 -2
View File
@@ -14,9 +14,11 @@ class BlogRepository extends ServiceEntityRepository
public function findBySlug(string $slug): ?Blog { return $this->findOneBy(['slug' => $slug]); }
/** @return Blog[] published, newest first */
public function findPublished(int $page = 1, int $limit = 20, ?string $tag = null): array
public function findPublished(int $page = 1, int $limit = 20, ?string $tag = null, ?int $cityId = null): array
{
$qb = $this->createQueryBuilder('b')
->leftJoin('b.city', 'c')
->addSelect('c')
->where('b.status = :status')
->setParameter('status', Blog::STATUS_PUBLISHED)
->orderBy('b.createdAt', 'DESC')
@@ -24,11 +26,12 @@ class BlogRepository extends ServiceEntityRepository
->setMaxResults($limit);
$this->applyTagFilter($qb, $tag);
$this->applyCityFilter($qb, $cityId);
return $qb->getQuery()->getResult();
}
public function countPublished(?string $tag = null): int
public function countPublished(?string $tag = null, ?int $cityId = null): int
{
$qb = $this->createQueryBuilder('b')
->select('COUNT(b.id)')
@@ -36,10 +39,26 @@ class BlogRepository extends ServiceEntityRepository
->setParameter('status', Blog::STATUS_PUBLISHED);
$this->applyTagFilter($qb, $tag);
$this->applyCityFilter($qb, $cityId);
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* دامنهٔ یک شهر باید پست‌های همان شهر **و** پست‌های سراسری را ببیند — پست
* سراسری (city NULL) روی همهٔ دامنه‌ها منتشر است، فقط canonicalش روی دامنهٔ اصلی
* می‌نشیند. بدون شرط NULL، دامنه‌های شهری محتوای عمومی را از دست می‌دادند.
*/
private function applyCityFilter(\Doctrine\ORM\QueryBuilder $qb, ?int $cityId): void
{
if ($cityId === null) {
return;
}
$qb->andWhere('b.city = :cityId OR b.city IS NULL')
->setParameter('cityId', $cityId);
}
private function applyTagFilter(\Doctrine\ORM\QueryBuilder $qb, ?string $tag): void
{
if ($tag === null || $tag === '') {