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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
Reference in New Issue
Block a user