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
+4
View File
@@ -16,9 +16,13 @@ import { api } from '@/lib/api';
import BlogFormPage from '@/pages/BlogFormPage';
const post = api.post as ReturnType<typeof vi.fn>;
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
post.mockReset();
// فرم شهرها را برای انتخابگر «سراسری / شهر» می‌گیرد
get.mockReset();
get.mockResolvedValue({ data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
});
describe('BlogFormPage — اعتبارسنجی zod (حالت ساخت)', () => {
+33 -1
View File
@@ -9,7 +9,8 @@ import { CKEditor } from '@ckeditor/ckeditor5-react';
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Blog } from '../types';
import type { Blog, City } from '../types';
import type { PaginatedResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect';
@@ -21,6 +22,8 @@ const schema = z.object({
tags: z.string().optional(),
status: z.enum(['draft', 'published']),
image_url: z.string().optional(),
// null = مقاله سراسری؛ حالت دائمی است نه مقدار تنظیم‌نشده
city_id: z.number().nullable().optional(),
});
type FormData = z.infer<typeof schema>;
@@ -59,6 +62,13 @@ export default function BlogFormPage() {
// detail endpoint is double-nested: success(['data' => $blog->toArray()])
const blog = data?.data?.data;
const citiesQuery = useQuery({
queryKey: ['cities-select'],
queryFn: () => api.get<PaginatedResponse<City>>('/api/v1/admin/cities?limit=200'),
staleTime: 5 * 60_000,
});
const cityOptions = (citiesQuery.data?.data ?? []).map((c) => ({ value: c.id, label: c.name }));
const { register, handleSubmit, control, watch, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { status: 'draft' },
@@ -70,6 +80,7 @@ export default function BlogFormPage() {
tags: blog.tags?.join(', ') ?? '',
status: blog.status,
image_url: blog.image_url ?? '',
city_id: blog.city ? Number(blog.city.id) : null,
}
: undefined,
});
@@ -83,6 +94,8 @@ export default function BlogFormPage() {
status: d.status,
image_url: d.image_url ?? '',
tags: d.tags ? d.tags.split(',').map((t) => t.trim()).filter(Boolean) : [],
// همیشه فرستاده می‌شود؛ null یعنی «سراسری» و باید شهر قبلی را پاک کند
city_id: d.city_id ?? null,
});
const createMutation = useMutation({
@@ -235,6 +248,25 @@ export default function BlogFormPage() {
/>
</div>
<div>
<label className="">شهر</label>
<Controller
control={control}
name="city_id"
render={({ field }) => (
<SearchableSelect
options={[{ value: 0, label: 'سراسری (همه شهرها)' }, ...cityOptions]}
value={field.value ?? 0}
onChange={(v) => field.onChange(v ? Number(v) : null)}
placeholder="سراسری (همه شهرها)"
/>
)}
/>
<p className="text-[12px] text-[var(--text-3)] mt-1">
مقاله سراسری روی همه دامنههای شهری نمایش داده میشود؛ مقاله شهری فقط به دامنه همان شهر نسبت داده میشود.
</p>
</div>
<div>
<label className="">تگها (با ویرگول جدا کنید)</label>
<input {...register('tags')} dir="ltr" placeholder="tag1, tag2, tag3"
+2
View File
@@ -428,6 +428,8 @@ export interface Blog {
image_url: string | null;
status: "draft" | "published";
author?: { uuid: string; name: string } | null;
/** null = مقاله سراسری (روی همه دامنه‌ها، canonical روی دامنه اصلی) */
city?: { id: string; name: string } | null;
tags: string[];
created_at: string;
updated_at?: string;