diff --git a/README.md b/README.md index 1dfa1c1f..1709ffdf 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,68 @@ rm -rf node_modules package-lock.json pnpm install ``` +## University Web Revalidation + +대학 지원 정보가 변경된 뒤 배포 없이 `university-web`의 정적 캐시를 무효화하려면 `POST /api/revalidate`를 호출한다. + +### Environment + +Vercel의 `solid-connect-university-web` 프로젝트에 다음 환경 변수가 필요하다. + +```bash +REVALIDATE_SECRET=your-secret +``` + +### Authentication + +다음 두 방식 중 하나로 secret을 전달할 수 있다. + +```bash +Authorization: Bearer ${REVALIDATE_SECRET} +``` + +```bash +x-revalidate-secret: ${REVALIDATE_SECRET} +``` + +### Revalidate All University Pages + +```bash +curl -X POST "https://www.solid-connection.com/api/revalidate" \ + -H "Authorization: Bearer ${REVALIDATE_SECRET}" \ + -H "Content-Type: application/json" \ + --data '{"scope":"university"}' +``` + +### Revalidate Home University Pages + +```bash +curl -X POST "https://www.solid-connection.com/api/revalidate" \ + -H "Authorization: Bearer ${REVALIDATE_SECRET}" \ + -H "Content-Type: application/json" \ + --data '{"scope":"home-university","homeUniversity":"kyunghee"}' +``` + +### Revalidate One Path + +```bash +curl -X POST "https://www.solid-connection.com/api/revalidate" \ + -H "Authorization: Bearer ${REVALIDATE_SECRET}" \ + -H "Content-Type: application/json" \ + --data '{"scope":"path","path":"/university/kyunghee"}' +``` + +### Response + +```json +{ + "revalidated": true, + "paths": ["/university/kyunghee", "/university/kyunghee/search"] +} +``` + +`revalidatePath`는 캐시를 무효화한다. 새 HTML은 API 호출 시점이 아니라 해당 페이지에 다음 요청이 들어올 때 다시 생성된다. + ## AI Inspector Workflow Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출됩니다. diff --git a/apps/university-web/src/app/api/revalidate/route.ts b/apps/university-web/src/app/api/revalidate/route.ts new file mode 100644 index 00000000..8ca30a1c --- /dev/null +++ b/apps/university-web/src/app/api/revalidate/route.ts @@ -0,0 +1,135 @@ +import { revalidatePath } from "next/cache"; +import { NextResponse } from "next/server"; + +import { getSearchUniversitiesAllRegions } from "@/apis/universities/server"; +import { getHomeUniversityBySlug, HOME_UNIVERSITY_LIST, HOME_UNIVERSITY_SLUGS } from "@/constants/university"; +import type { HomeUniversitySlug } from "@/types/university"; + +type RevalidateScope = "university" | "home-university" | "path"; + +type RevalidateRequestBody = { + scope?: RevalidateScope; + homeUniversity?: HomeUniversitySlug; + path?: string; + includeDetails?: boolean; +}; + +const getBearerToken = (authorizationHeader: string | null) => { + if (!authorizationHeader?.startsWith("Bearer ")) { + return null; + } + + return authorizationHeader.slice("Bearer ".length).trim(); +}; + +const isAuthorized = (request: Request) => { + const secret = process.env.REVALIDATE_SECRET; + + if (!secret) { + return false; + } + + const requestSecret = + getBearerToken(request.headers.get("authorization")) ?? request.headers.get("x-revalidate-secret"); + + return requestSecret === secret; +}; + +const isAllowedUniversityPath = (path: string) => { + if (!path.startsWith("/university")) { + return false; + } + + if (path.includes("://") || path.includes("..")) { + return false; + } + + return ( + path === "/university" || path === "/university/search" || /^\/university\/[a-z0-9-]+(\/search|\/\d+)?$/.test(path) + ); +}; + +const revalidateUniversityPath = (path: string, revalidatedPaths: Set) => { + revalidatePath(path); + revalidatedPaths.add(path); +}; + +const revalidateHomeUniversityPaths = async ( + homeUniversity: HomeUniversitySlug, + includeDetails: boolean, + revalidatedPaths: Set, +) => { + const homeUniversityInfo = getHomeUniversityBySlug(homeUniversity); + + if (!homeUniversityInfo) { + throw new Error(`Invalid homeUniversity: ${homeUniversity}`); + } + + revalidateUniversityPath(`/university/${homeUniversity}`, revalidatedPaths); + revalidateUniversityPath(`/university/${homeUniversity}/search`, revalidatedPaths); + + if (!includeDetails) { + return; + } + + const universities = await getSearchUniversitiesAllRegions({ + homeUniversityId: homeUniversityInfo.homeUniversityId, + }); + + for (const university of universities) { + revalidateUniversityPath(`/university/${homeUniversity}/${university.id}`, revalidatedPaths); + } +}; + +const parseRequestBody = async (request: Request): Promise => { + try { + return (await request.json()) as RevalidateRequestBody; + } catch { + return {}; + } +}; + +export async function POST(request: Request) { + if (!isAuthorized(request)) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const body = await parseRequestBody(request); + const scope = body.scope ?? "university"; + const includeDetails = body.includeDetails ?? true; + const revalidatedPaths = new Set(); + + try { + if (scope === "path") { + if (!body.path || !isAllowedUniversityPath(body.path)) { + return NextResponse.json({ message: "Invalid revalidation path" }, { status: 400 }); + } + + revalidateUniversityPath(body.path, revalidatedPaths); + } else if (scope === "home-university") { + if (!body.homeUniversity || !HOME_UNIVERSITY_SLUGS.includes(body.homeUniversity)) { + return NextResponse.json({ message: "Invalid homeUniversity" }, { status: 400 }); + } + + await revalidateHomeUniversityPaths(body.homeUniversity, includeDetails, revalidatedPaths); + } else if (scope === "university") { + revalidateUniversityPath("/university", revalidatedPaths); + revalidateUniversityPath("/university/search", revalidatedPaths); + + for (const homeUniversity of HOME_UNIVERSITY_LIST) { + await revalidateHomeUniversityPaths(homeUniversity.slug, includeDetails, revalidatedPaths); + } + } else { + return NextResponse.json({ message: "Invalid revalidation scope" }, { status: 400 }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + return NextResponse.json({ message: "Failed to revalidate", error: errorMessage }, { status: 500 }); + } + + return NextResponse.json({ + revalidated: true, + paths: Array.from(revalidatedPaths), + }); +} diff --git a/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx index 677e30ce..29cc0e7c 100644 --- a/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx +++ b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx @@ -12,7 +12,7 @@ import { createUrl, NO_INDEX_ROBOTS } from "@/utils/seo"; import UniversityDetail from "./_ui/UniversityDetail"; import UniversityDetailPreparingFallback from "./_ui/UniversityDetailPreparingFallback"; -export const revalidate = false; // 완전 정적 생성 +export const revalidate = false; export const dynamicParams = false; // 모든 homeUniversity + id 조합에 대해 정적 경로 생성