admin/lib/seo/serverApi.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

470 lines
16 KiB
TypeScript

import type {
BlogArticleResponseDto,
BlogArticleSummaryResponseDto,
CityResponseDto,
DiscoveryCategorySummaryDto,
DiscoveryEventResponseDto,
EventCategoryResponseDto,
HomeFeedResponseDto,
} from '@/api/generated/models'
import { isSeoEligibleEvent } from '@/lib/seo/contentQuality'
import type { EventFaq, EventMedia } from '@/services/eventDetail'
import type { EventReview, OrganizerReview } from '@/services/reviews'
/**
* Server-only data fetching for public SEO pages (App Router server
* components). Deliberately does NOT reuse `@/config/axios` — that
* instance is wired for the browser (js-cookie, refresh-on-401 via
* `window`) and isn't safe to import into a server component. These
* pages are public/unauthenticated (`@Public()` backend routes), so a
* bare `fetch` against `NEXT_PUBLIC_API_URL` is enough.
*
* Owned by saza2 (docs/prompt/saza2.md) as part of the Phase 3 SEO
* foundation stub — see that file and `_shared.md` for why this exists
* ahead of saza1's real foundation task.
*/
export interface SeoCity {
id: number
provinceId: number
provinceName: string
provinceSlug: string
name: string
slug: string
lat: number
lng: number
}
export type SeoCategory = EventCategoryResponseDto
export type SeoDiscoveryEvent = DiscoveryEventResponseDto
function resolveApiBaseUrl(): string {
const raw = process.env.NEXT_PUBLIC_API_URL
if (!raw) {
throw new Error('NEXT_PUBLIC_API_URL is not configured')
}
const trimmed = raw.replace(/\/+$/, '')
return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`
}
type ApiEnvelope<T> = { success: true; data: T } | { success: false; message: string; code?: string }
/** Server-cache public discovery reference data for five days. */
const DISCOVERY_REFERENCE_REVALIDATE_SECONDS = 5 * 24 * 60 * 60
async function publicGet<T>(path: string, params?: Record<string, string | number>, revalidate: number | false = 300): Promise<T | null> {
const url = new URL(`${resolveApiBaseUrl()}/${path.replace(/^\/+/, '')}`)
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value))
}
}
const response = await fetch(url.toString(), { next: { revalidate } })
if (response.status === 404) return null
if (!response.ok) {
throw new Error(`SEO fetch failed: ${response.status} ${url.pathname}`)
}
const body = (await response.json()) as ApiEnvelope<T>
if (!body.success) {
throw new Error(body.message || 'SEO fetch failed')
}
return body.data
}
/** `GET /geography/cities/:slug` — see backend geography.controller.ts */
export async function fetchPublicCityBySlug(slug: string): Promise<SeoCity | null> {
return publicGet<SeoCity>(`geography/cities/${encodeURIComponent(slug)}`)
}
export type SeoCityListItem = CityResponseDto
export type SeoDiscoveryCategory = DiscoveryCategorySummaryDto
export interface DiscoveryBootstrap {
categories: SeoCategory[]
cities: SeoCityListItem[]
}
/** Cached city list for home/discovery (`GET /discovery/cities`) — refreshed every five days. */
export async function fetchDiscoveryCities(): Promise<SeoCityListItem[]> {
const data = await publicGet<SeoCityListItem[]>('discovery/cities', undefined, DISCOVERY_REFERENCE_REVALIDATE_SECONDS)
if (!data) throw new Error('Discovery cities fetch returned no data')
return data
}
/** Cached category tree summary for home/discovery (`GET /discovery/categories`) — refreshed every five days. */
export async function fetchDiscoveryCategories(): Promise<SeoDiscoveryCategory[]> {
const data = await publicGet<SeoDiscoveryCategory[]>('discovery/categories', undefined, DISCOVERY_REFERENCE_REVALIDATE_SECONDS)
if (!data) throw new Error('Discovery categories fetch returned no data')
return data
}
export type SeoHomeFeed = HomeFeedResponseDto
/** Home sections payload (`GET /discovery/home-feed`). Revalidate faster than reference data. */
export async function fetchDiscoveryHomeFeed(cityId?: number): Promise<SeoHomeFeed> {
const params: Record<string, string | number> = {}
if (cityId != null) params.cityId = cityId
const data = await publicGet<SeoHomeFeed>('discovery/home-feed', params, 60)
if (!data) throw new Error('Discovery home feed fetch returned no data')
return data
}
/** @deprecated Prefer `fetchDiscoveryCities` + `fetchDiscoveryCategories`. */
export async function fetchDiscoveryBootstrap(): Promise<DiscoveryBootstrap> {
const data = await publicGet<DiscoveryBootstrap>('discovery/bootstrap', undefined, false)
if (!data) throw new Error('Discovery bootstrap fetch returned no data')
return data
}
/**
* `GET /geography/cities` (unpaginated flat list, `@Public()`). Used by
* the `/city` hub. NOTE: `geography/provinces` isn't fetched here on
* purpose — provinces don't carry a slug/dedicated landing page in this
* stub, so the hub groups by the city row's own `provinceId` and shows
* a numeric fallback if a province name lookup is added later.
*/
export async function fetchPublicCities(): Promise<SeoCityListItem[]> {
const url = new URL(`${resolveApiBaseUrl()}/geography/cities`)
const response = await fetch(url.toString(), { next: { revalidate: 300 } })
if (!response.ok) throw new Error(`SEO cities fetch failed: ${response.status}`)
const body = (await response.json()) as { data?: unknown }
const data = body.data
return Array.isArray(data) ? (data as SeoCityListItem[]) : []
}
export interface SeoProvince {
id: number
name: string
}
/** `GET /geography/provinces` (unpaginated, `@Public()`) — for grouping the city hub by province name. */
export async function fetchPublicProvinces(): Promise<SeoProvince[]> {
const url = new URL(`${resolveApiBaseUrl()}/geography/provinces`)
const response = await fetch(url.toString(), { next: { revalidate: 300 } })
if (!response.ok) throw new Error(`SEO provinces fetch failed: ${response.status}`)
const body = (await response.json()) as { data?: unknown }
const data = body.data
return Array.isArray(data) ? (data as SeoProvince[]) : []
}
/** `GET /event-categories/:slug` — pre-existing public endpoint */
export async function fetchPublicCategoryBySlug(slug: string): Promise<SeoCategory | null> {
return publicGet<SeoCategory>(`event-categories/${encodeURIComponent(slug)}`)
}
/** `GET /event-categories` (flat, active-only, unpaginated, `@Public()`) — used by the `/category` hub. */
export async function fetchPublicCategories(): Promise<SeoCategory[]> {
const url = new URL(`${resolveApiBaseUrl()}/event-categories`)
const response = await fetch(url.toString(), { next: { revalidate: 300 } })
if (!response.ok) throw new Error(`SEO categories fetch failed: ${response.status}`)
const body = (await response.json()) as { data?: unknown }
const data = body.data
return Array.isArray(data) ? (data as SeoCategory[]) : []
}
export interface SeoEventDetail {
id: string
organizerId: string
categoryId: number
/** Present on `GET /events/:id` and related EventResponseDto payloads. */
categoryName?: string
cityName?: string
title: string
slug: string
shortDescription: string | null
description: string | null
startsAt: string
endsAt: string
provinceId: number
cityId: number
address: string | null
lat: number | null
lng: number | null
isFree: boolean
price: string | number
capacity: number
bookedCount: number
reservedCapacity?: number
status: string
isFeatured: boolean
posterUrl: string | null
avgRating?: number | null
reviewsCount?: number
genderRestriction?: string | null
ageRestriction?: string | null
settings?: {
isDiscoverable: boolean
addressVisibility: 'public' | 'attendees_only'
generalArea: string | null
}
}
export interface SeoEventLandingOrganizer {
id: string
firstName: string
lastName: string
avatarUrl: string | null
gender: 'male' | 'female' | 'other' | null
followersCount: number
pastEventsCount: number
contactLinks: { channel: string; label: string; url: string }[]
}
export interface SeoEventLandingBootstrap {
event: SeoEventDetail
category: { id: number; name: string; slug: string }
city: { id: number; name: string }
media: EventMedia[]
faqs: EventFaq[]
organizer: SeoEventLandingOrganizer
reviews: EventReview[]
reviewsTotal: number
}
/** One public/cacheable payload for every non-personalized landing section. */
export async function fetchPublicEventLandingBootstrap(slug: string): Promise<SeoEventLandingBootstrap | null> {
return publicGet<SeoEventLandingBootstrap>(`events/by-slug/${encodeURIComponent(slug)}/bootstrap`, undefined, 60)
}
/** `GET /events/by-slug/:slug` — public, discoverable events only (backend/src/modules/events/events.controller.ts). */
export async function fetchPublicEventBySlug(slug: string): Promise<SeoEventDetail | null> {
return publicGet<SeoEventDetail>(`events/by-slug/${encodeURIComponent(slug)}`)
}
export type LegacyEventRedirectLookup = Pick<SeoEventDetail, 'id' | 'slug' | 'status'> & {
settings?: { isDiscoverable?: boolean }
}
export type LegacyEventDetail = SeoEventDetail & {
settings: {
isDiscoverable: boolean
addressVisibility: 'public' | 'attendees_only'
generalArea: string | null
}
}
/**
* Resolves a legacy UUID event URL before rendering. The response is used only
* to choose a canonical redirect; no private event content is rendered from
* this server-side lookup.
*/
export async function fetchLegacyEventRedirectById(id: string): Promise<LegacyEventDetail | null> {
return publicGet<LegacyEventDetail>(`events/${encodeURIComponent(id)}`, undefined, 60)
}
/** Authenticated legacy detail read. It is deliberately excluded from every public/server cache. */
export async function fetchLegacyEventForViewer(id: string, accessToken: string): Promise<LegacyEventDetail | null> {
const url = new URL(`${resolveApiBaseUrl()}/events/${encodeURIComponent(id)}`)
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (response.status === 404) return null
if (!response.ok) throw new Error(`Event detail fetch failed: ${response.status}`)
const body = (await response.json()) as ApiEnvelope<LegacyEventDetail>
if (!body.success) throw new Error(body.message || 'Event detail fetch failed')
return body.data
}
export interface SeoEventFilters {
cityId?: number
categoryId?: number
categoryIds?: number[]
page?: number
pageSize?: number
}
export interface PublicOrganizer {
id: string
firstName: string
lastName: string
avatarUrl: string | null
gender: 'male' | 'female' | 'other' | null
bio: string | null
cityName: string | null
defaultAddress: string | null
followersCount: number
isVerified: boolean
memberSince: string
pastEventsCount: number
totalGuestsCount?: number
avgRating?: number | null
reviewsCount?: number
contactLinks: { channel: string; label: string; url: string; value: string }[]
events: {
id: string
slug: string
title: string
shortDescription: string | null
posterUrl: string | null
startsAt: string
cityName: string
isFree: boolean
price: string | number
isPast: boolean
}[]
}
export async function fetchPublicOrganizer(id: string): Promise<PublicOrganizer | null> {
return publicGet<PublicOrganizer>(`users/public/${encodeURIComponent(id)}`, undefined, 300)
}
export interface PublicOrganizerBootstrap {
organizer: PublicOrganizer
reviews: OrganizerReview[]
reviewsTotal: number
}
/** One public/cacheable payload for the organizer profile and first review page. */
export async function fetchPublicOrganizerBootstrap(id: string): Promise<PublicOrganizerBootstrap | null> {
return publicGet<PublicOrganizerBootstrap>(`users/public/${encodeURIComponent(id)}/bootstrap`, undefined, 300)
}
/**
* `GET /events` (public discovery) filtered for SEO landing pages.
* Mirrors `frontend/services/discovery.ts` `fetchDiscoveryEvents`
* (client-side, axios-based) but safe to call from a server component.
*/
export interface SeoDiscoveryPage {
items: SeoDiscoveryEvent[]
totalItemsCount: number
sourceItemsCount: number
sourceTotalItemsCount: number
}
export async function fetchSeoDiscoveryEvents(filters: SeoEventFilters): Promise<SeoDiscoveryPage> {
const params: Record<string, string | number> = {
page: filters.page ?? 1,
pageSize: filters.pageSize ?? 24,
}
if (filters.cityId !== undefined) params['filters[cityId]'] = filters.cityId
if (filters.categoryIds?.length) params['filters[categoryId]'] = filters.categoryIds.join(',')
else if (filters.categoryId !== undefined) params['filters[categoryId]'] = filters.categoryId
const url = new URL(`${resolveApiBaseUrl()}/events`)
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value))
}
const response = await fetch(url.toString(), { next: { revalidate: 300 } })
if (!response.ok) {
throw new Error(`SEO discovery fetch failed: ${response.status}`)
}
const body = (await response.json()) as { data?: { items?: unknown; response?: { totalItemsCount?: unknown } } }
const data = body.data ?? {}
const rawItems = Array.isArray(data.items) ? (data.items as SeoDiscoveryEvent[]) : []
const items = rawItems.filter(isSeoEligibleEvent)
const reportedTotal = Number(data.response?.totalItemsCount ?? rawItems.length)
const totalItemsCount = Math.max(0, reportedTotal - (rawItems.length - items.length))
return {
items,
totalItemsCount,
sourceItemsCount: rawItems.length,
sourceTotalItemsCount: reportedTotal,
}
}
/** Fetches every public discovery page so sitemap generation never drops events after page one. */
export async function fetchAllSeoDiscoveryEvents(pageSize = 100): Promise<SeoDiscoveryEvent[]> {
const firstPage = await fetchSeoDiscoveryEvents({ page: 1, pageSize })
const totalPages = Math.ceil(firstPage.sourceTotalItemsCount / pageSize)
if (totalPages <= 1) return firstPage.items
const remainingPages = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, index) => fetchSeoDiscoveryEvents({ page: index + 2, pageSize }))
)
return [firstPage, ...remainingPages].flatMap((page) => page.items)
}
export type SeoArticle = BlogArticleResponseDto
export type SeoArticleSummary = BlogArticleSummaryResponseDto
export interface SeoArticleFilters {
categorySlug?: string
citySlug?: string
eventCategorySlug?: string
featured?: boolean
}
const BLOG_REVALIDATE_SECONDS = 60
/**
* `GET /blog-articles` (published-only, unpaginated, `@Public()`) — replaces
* the old static `frontend/content/articles.ts` import. Used by `/blog`,
* `/blog/category/[slug]`, `/blog/city/[slug]` and `sitemap.ts`.
*/
export async function fetchPublishedArticles(filters: SeoArticleFilters = {}): Promise<SeoArticleSummary[]> {
const url = new URL(`${resolveApiBaseUrl()}/blog-articles`)
if (filters.categorySlug) url.searchParams.set('categorySlug', filters.categorySlug)
if (filters.citySlug) url.searchParams.set('citySlug', filters.citySlug)
if (filters.eventCategorySlug) url.searchParams.set('eventCategorySlug', filters.eventCategorySlug)
if (filters.featured !== undefined) url.searchParams.set('featured', String(filters.featured))
const response = await fetch(url.toString(), { next: { revalidate: BLOG_REVALIDATE_SECONDS } })
if (!response.ok) throw new Error(`SEO articles fetch failed: ${response.status}`)
const body = (await response.json()) as { data?: unknown }
const data = body.data
return Array.isArray(data) ? (data as SeoArticleSummary[]) : []
}
/** `GET /blog-articles/:slug` — published-only, `@Public()`. */
export async function fetchArticleBySlug(slug: string): Promise<SeoArticle | null> {
return publicGet<SeoArticle>(`blog-articles/${encodeURIComponent(slug)}`, undefined, BLOG_REVALIDATE_SECONDS)
}
/** `GET /blog-articles/:slug/related` — lightweight card data only. */
export async function fetchRelatedArticles(slug: string, limit = 3): Promise<SeoArticleSummary[]> {
const result = await publicGet<SeoArticleSummary[]>(
`blog-articles/${encodeURIComponent(slug)}/related`,
{ limit },
BLOG_REVALIDATE_SECONDS
)
return result ?? []
}