diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.js index 1004ef1..d6e4e75 100644 --- a/app/blog/[slug]/page.js +++ b/app/blog/[slug]/page.js @@ -4,30 +4,45 @@ import Layout from "@/components/layout/StLayout"; import { notFound } from "next/navigation"; import { fetchReq } from "@/lib/req"; import { getStateInfo } from "@/lib/getStateInfo"; -import { getEntityOrigin } from "@/lib/getCanonicalUrl"; -import { extractEntityCityId, findCityById } from "@/lib/domainHelpers"; +import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl"; +import { + domainScopeCityId, + extractEntityCityId, + findCityById, +} from "@/lib/domainHelpers"; import { safeJsonLd } from "@/lib/sanitize"; import { normalizeBlog, imageUrl } from "@/helper"; const FALLBACK_IMG = "/assets/images/og-image.png"; const API_URL = process.env.NEXT_PUBLIC_API_URL; -const getBlog = cache(async (slug) => { - const res = await fetch(`${API_URL}/api/v1/blog/${slug}`, { +// scope دامنه در کلید cache می‌نشیند: یک پست روی دامنهٔ شهریِ دیگر ۴۰۴ است، پس +// پاسخِ دامنهٔ ریشه نباید به دامنهٔ شهری نشت کند. +const getBlog = cache(async (slug, cityId) => { + const query = cityId != null ? `?city_id=${cityId}` : ""; + const res = await fetch(`${API_URL}/api/v1/blog/${slug}${query}`, { next: { revalidate: 3600, tags: [`blog-${slug}`] }, }); + // ۴۰۴ یعنی «روی این دامنه وجود ندارد» — پستِ شهرِ دیگر (یا پستِ دامنهٔ اصلی) + // نباید روی دامنهٔ شهری باز شود؛ بک‌اند همان فیلتر لیست را اینجا هم اعمال می‌کند. if (res.status === 404 || res.status === 400) return null; if (!res.ok) throw new Error(`Failed to fetch blog: ${res.status}`); const json = await res.json(); return json?.data?.data ?? null; }); +/** پست را در scope دامنهٔ جاری می‌گیرد (دامنهٔ ریشه scope ندارد). */ +async function getScopedBlog(slug) { + const { matchedCity, isRoot } = await getStateInfo(); + return getBlog(slug, domainScopeCityId({ matchedCity, isRoot })); +} + export async function generateMetadata({ params }) { const { slug } = await params; const { matchedCity } = await getStateInfo(); const siteName = matchedCity?.site_name || "نوبت 724"; - const blog = await getBlog(slug); + const blog = await getScopedBlog(slug); if (!blog) notFound(); try { @@ -63,10 +78,11 @@ export async function generateMetadata({ params }) { ? imageUrl(blog.image_url) : FALLBACK_IMG; - // C1-b — پست شهریافته به دامنهٔ همان شهر canonical می‌شود؛ - // پست سراسری (بدون شهر) روی دامنهٔ جاری self-canonical می‌ماند. + // C1-b — پست شهریافته به دامنهٔ همان شهر canonical می‌شود؛ پست سراسری (یا پستِ + // رکورد ریشهٔ nobat724) به دامنهٔ اصلی، نه دامنهٔ جاری — وگرنه یک محتوا روی چند + // دامنه سرو می‌شد و هرکدام خودش را canonical اعلام می‌کرد. // فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپ‌لاین) مقدم است. - const origin = await getEntityOrigin(extractEntityCityId(blog)); + const origin = getBlogCanonicalOrigin(extractEntityCityId(blog)); const canonical = blog.canonical_url || `${origin}/blog/${slug}`; return { @@ -102,16 +118,20 @@ export async function generateMetadata({ params }) { async function Blog({ params }) { const { slug } = await params; - const blog = normalizeBlog(await getBlog(slug)); + const blog = normalizeBlog(await getScopedBlog(slug)); if (!blog) notFound(); const blogCityId = extractEntityCityId(blog); const blogCity = findCityById(blogCityId); - const origin = await getEntityOrigin(blogCityId); + const origin = getBlogCanonicalOrigin(blogCityId); + // «مقالات مرتبط» هم باید scope دامنه را رعایت کند، وگرنه روی دامنهٔ شهری لینکِ + // پستِ شهر دیگر نشان داده می‌شود که حالا ۴۰۴ می‌گیرد. + const { matchedCity, isRoot } = await getStateInfo(); + const scopeCityId = domainScopeCityId({ matchedCity, isRoot }); let relatedBlogs = []; const relatedResponse = await fetchReq( - `${API_URL}/api/v1/blogs?page=1&limit=6` + `${API_URL}/api/v1/blogs?page=1&limit=6${scopeCityId != null ? `&city_id=${scopeCityId}` : ""}` ); relatedBlogs = (relatedResponse?.data || []) .filter((b) => b.uuid !== blog?.uuid) diff --git a/lib/domainHelpers.js b/lib/domainHelpers.js index 94cae80..e696481 100644 --- a/lib/domainHelpers.js +++ b/lib/domainHelpers.js @@ -27,6 +27,32 @@ export function findCityById(cityId) { return city && !isRootCity(city) ? city : null; } +/** + * شناسهٔ شهری که باید به‌عنوان scope دامنه به API فرستاده شود. + * دامنهٔ ریشه (nobat724) و دامنهٔ ناشناخته scope ندارند → همهٔ محتوا را می‌بینند؛ + * دامنهٔ شهری فقط پست‌های خودش + سراسری‌ها را (فیلتر سمت بک‌اند). + * @returns {number|null} + */ +export function domainScopeCityId({ matchedCity, isRoot } = {}) { + if (isRoot) return null; + const id = Number(matchedCity?.id); + return Number.isFinite(id) ? id : null; +} + +/** + * آیا این پست روی این دامنه وجود دارد؟ آینهٔ فیلتر بک‌اند + * (`b.city = :cityId OR b.city IS NULL`) برای استفاده در تست و رندر سمت کلاینت. + * پست بدون شهر سراسری است و همه‌جا دیده می‌شود؛ پست شهریافته (از جمله پستِ رکورد + * ریشهٔ nobat724) فقط روی دامنهٔ خودش. + */ +export function isBlogVisibleOnDomain(blog, scope = {}) { + const scopeCityId = domainScopeCityId(scope); + if (scopeCityId === null) return true; // دامنهٔ ریشه/ناشناخته: بدون محدودیت + const ownerId = blog?.city?.id ?? blog?.city_id ?? null; + if (ownerId == null) return true; // سراسری + return Number(ownerId) === scopeCityId; +} + /** * نام قابل‌نمایش شهر برای H1/Title. * روی دامنهٔ ریشه نام رکورد («نوبت 724») یک برند است نه شهر — به‌جای آن «ایران». diff --git a/lib/domainHelpers.test.js b/lib/domainHelpers.test.js index a9f46f8..a318589 100644 --- a/lib/domainHelpers.test.js +++ b/lib/domainHelpers.test.js @@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest"; import citiesData from "@/data/city.json"; import { ROOT_CITY_ID } from "@/lib/rootCity"; import { + domainScopeCityId, extractEntityCityId, findDomainByCityId, + isBlogVisibleOnDomain, resolveCityDisplayName, } from "@/lib/domainHelpers"; +import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl"; const YASUJ = citiesData.find((c) => c.domain === "yasuj-nobat.ir"); const ROOT = citiesData.find((c) => Number(c.id) === ROOT_CITY_ID); @@ -89,3 +92,53 @@ describe("شکل پاسخ بلاگ (city آبجکت یا null)", () => { expect(findDomainByCityId(extractEntityCityId(post))).toBeNull(); }); }); + +describe("scope دامنه برای API", () => { + it("دامنهٔ ریشه scope ندارد (همهٔ پست‌ها را می‌بیند)", () => { + expect(domainScopeCityId({ matchedCity: ROOT, isRoot: true })).toBeNull(); + }); + + it("دامنهٔ شهری با شناسهٔ همان شهر scope می‌شود", () => { + expect(domainScopeCityId({ matchedCity: YASUJ, isRoot: false })).toBe(Number(YASUJ.id)); + }); + + it("دامنهٔ ناشناخته (نمایندگی) scope ندارد", () => { + expect(domainScopeCityId({ matchedCity: undefined, isRoot: false })).toBeNull(); + expect(domainScopeCityId()).toBeNull(); + }); +}); + +describe("دیده‌شدن پست روی دامنه (آینهٔ فیلتر بک‌اند)", () => { + const yasujScope = { matchedCity: YASUJ, isRoot: false }; + const rootScope = { matchedCity: ROOT, isRoot: true }; + + it("پست همان شهر روی دامنهٔ خودش دیده می‌شود", () => { + expect(isBlogVisibleOnDomain({ city: { id: String(YASUJ.id) } }, yasujScope)).toBe(true); + }); + + it("پست شهر دیگر روی این دامنه وجود ندارد", () => { + const ahvaz = citiesData.find((c) => c.domain === "ahvaz-nobat.ir"); + expect(isBlogVisibleOnDomain({ city: { id: String(ahvaz.id) } }, yasujScope)).toBe(false); + }); + + it("پستِ رکورد ریشه (nobat724) روی دامنهٔ شهری وجود ندارد — همان باگ گزارش‌شده", () => { + expect(isBlogVisibleOnDomain({ city: { id: String(ROOT_CITY_ID) } }, yasujScope)).toBe(false); + expect(isBlogVisibleOnDomain({ city: { id: String(ROOT_CITY_ID) } }, rootScope)).toBe(true); + }); + + it("پست سراسری (city: null) همه‌جا دیده می‌شود", () => { + expect(isBlogVisibleOnDomain({ city: null }, yasujScope)).toBe(true); + expect(isBlogVisibleOnDomain({ city: null }, rootScope)).toBe(true); + }); +}); + +describe("canonical پست بلاگ", () => { + it("پست شهری → دامنهٔ همان شهر", () => { + expect(getBlogCanonicalOrigin(YASUJ.id)).toBe("https://yasuj-nobat.ir"); + }); + + it("پست سراسری یا پستِ رکورد ریشه → دامنهٔ اصلی، نه دامنهٔ جاری", () => { + expect(getBlogCanonicalOrigin(null)).toBe("https://nobat724.com"); + expect(getBlogCanonicalOrigin(ROOT_CITY_ID)).toBe("https://nobat724.com"); + }); +}); diff --git a/lib/getCanonicalUrl.js b/lib/getCanonicalUrl.js index 60d0f66..2948ce8 100644 --- a/lib/getCanonicalUrl.js +++ b/lib/getCanonicalUrl.js @@ -121,6 +121,19 @@ export async function getEntityOrigin(cityId) { return getEntityCanonical(cityId, "/", currentHost).replace(/\/$/, ""); } +/** + * origin canonical یک پست بلاگ. + * + * فرق آن با getEntityOrigin: پستی که دامنهٔ شهری ندارد (سراسری، یا متعلق به رکورد + * ریشهٔ nobat724) نباید روی دامنهٔ جاری self-canonical شود — همان محتوا روی چند + * دامنه سرو می‌شد و هر کدام خودش را canonical اعلام می‌کرد (duplicate content). + * مرجع چنین پستی همیشه دامنهٔ اصلی است. + */ +export function getBlogCanonicalOrigin(cityId) { + const cityDomain = findDomainByCityId(cityId); + return cityDomain ? buildCanonicalUrl(cityDomain, "/").replace(/\/$/, "") : MAIN_ORIGIN; +} + /** * canonical پیش‌فرض لایهٔ layout — برای صفحاتی که خودشان alternates.canonical نمی‌دهند. * صفحات موجودیت canonical خودشان را در generateMetadata صفحه تعیین می‌کنند.