From 5d18af932d66b086d9a628b2ed5f58eab13b55ff Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 11 Sep 2026 00:19:12 +0200 Subject: [PATCH] feat(news): add searchable paginated publication archive --- src/app/(site)/news/page.tsx | 92 +++++++++++++++++++++++++-- src/lib/services/news-archive.test.ts | 65 +++++++++++++++++++ src/lib/services/news-archive.ts | 55 ++++++++++++++++ 3 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 src/lib/services/news-archive.test.ts create mode 100644 src/lib/services/news-archive.ts diff --git a/src/app/(site)/news/page.tsx b/src/app/(site)/news/page.tsx index 24c65940..a05b83cc 100644 --- a/src/app/(site)/news/page.tsx +++ b/src/app/(site)/news/page.tsx @@ -4,21 +4,70 @@ import Link from "@/components/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { excerpt } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; -import { getNewsList } from "@/lib/services/news-list"; +import { logger } from "@/lib/logger"; +import { loadNewsArchive } from "@/lib/services/news-archive"; export const metadata = { title: "News" }; -export default async function NewsPage() { +export default async function NewsPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string; page?: string; order?: string }>; +}) { const t = await getTranslations("pages.news"); - const articles = await getNewsList(30); + const params = await searchParams; + const result = await loadNewsArchive({ + search: params.q, + page: Number(params.page ?? 1), + order: params.order, + }).catch((error) => { + logger.error("Public news archive failed", { module: "news", error }); + return null; + }); + const articles = result?.rows ?? []; + const href = (page: number) => + `/news?${new URLSearchParams({ q: result?.search ?? params.q ?? "", order: result?.order ?? "newest", page: String(page) })}`; return (
+ +
+ + + +
+
- {articles.length === 0 ? ( + {!result ? ( +

{t("loadError")}

+ ) : articles.length === 0 ? ( {t("noArticles")} ) : (
@@ -82,7 +131,10 @@ export default async function NewsPage() {

{formatDate(a.createdAt, "date", "")}

@@ -103,6 +155,36 @@ export default async function NewsPage() {
)}
+ {result && ( + + + + )}
); } diff --git a/src/lib/services/news-archive.test.ts b/src/lib/services/news-archive.test.ts new file mode 100644 index 00000000..cfbc6ac9 --- /dev/null +++ b/src/lib/services/news-archive.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + total: 55, + fail: false, + queries: [] as { sql: string; params: unknown[] }[], +})); +vi.mock("@/lib/db", async () => { + const schema = await import("@/db/schema"); + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + return { + ...schema, + db: drizzle(async (sql, params) => { + if (state.fail) throw new Error("unavailable"); + state.queries.push({ sql, params }); + return { rows: sql.includes("count(*)") ? [[state.total]] : [] }; + }), + }; +}); + +import { loadNewsArchive } from "./news-archive"; + +describe("public news archive", () => { + beforeEach(() => { + state.total = 55; + state.fail = false; + state.queries = []; + }); + it("filters unpublished and future articles before counting and paginating", async () => { + const result = await loadNewsArchive({ search: "needle'", page: 2 }); + expect(result).toMatchObject({ total: 55, page: 2, lastPage: 3 }); + for (const query of state.queries) { + expect(query.sql).toContain("`status` = ?"); + expect(query.sql).toContain("<= NOW()"); + expect(query.params.slice(0, 3)).toEqual([ + "published", + "%needle'%", + "%needle'%", + ]); + } + expect(state.queries[1].params.slice(-2)).toEqual([24, 24]); + expect(state.queries[1].sql).toMatch( + /order by COALESCE.* desc, .*`id` desc/, + ); + }); + it("orders oldest first by publication time and bounds the last page", async () => { + expect(await loadNewsArchive({ page: 999, order: "oldest" })).toMatchObject( + { page: 3, order: "oldest" }, + ); + expect(state.queries[1].sql).toMatch(/order by COALESCE.* asc, .*`id` asc/); + expect(state.queries[1].params.slice(-2)).toEqual([24, 48]); + }); + it("handles empty results and invalid pagination", async () => { + state.total = 0; + expect(await loadNewsArchive({ page: NaN, order: "bad" })).toMatchObject({ + page: 1, + lastPage: 1, + order: "newest", + }); + }); + it("propagates failures instead of presenting them as an empty archive", async () => { + state.fail = true; + await expect(loadNewsArchive()).rejects.toThrow(); + }); +}); diff --git a/src/lib/services/news-archive.ts b/src/lib/services/news-archive.ts new file mode 100644 index 00000000..3a57ca2e --- /dev/null +++ b/src/lib/services/news-archive.ts @@ -0,0 +1,55 @@ +import "server-only"; +import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm"; +import { db, WebsiteArticles } from "@/lib/db"; + +export async function loadNewsArchive( + options: { search?: string; page?: number; order?: string } = {}, +) { + const search = (options.search ?? "").trim().slice(0, 191); + const order = options.order === "oldest" ? "oldest" : "newest"; + const publishedAt = + sql`COALESCE(${WebsiteArticles.publishAt}, ${WebsiteArticles.createdAt})`.mapWith( + WebsiteArticles.createdAt, + ); + const where = and( + eq(WebsiteArticles.status, "published"), + or( + sql`${WebsiteArticles.publishAt} IS NULL`, + sql`${WebsiteArticles.publishAt} <= NOW()`, + ), + search + ? or( + like(WebsiteArticles.title, `%${search}%`), + like(WebsiteArticles.shortStory, `%${search}%`), + ) + : undefined, + ); + const totals = await db + .select({ value: count() }) + .from(WebsiteArticles) + .where(where); + const total = Number(totals[0]?.value ?? 0); + const perPage = 24; + const lastPage = Math.max(1, Math.ceil(total / perPage)); + const page = Math.min( + lastPage, + Number.isFinite(options.page) + ? Math.max(1, Math.trunc(options.page ?? 1)) + : 1, + ); + const sort = order === "oldest" ? asc : desc; + const rows = await db + .select({ + slug: WebsiteArticles.slug, + title: WebsiteArticles.title, + shortStory: WebsiteArticles.shortStory, + image: WebsiteArticles.image, + createdAt: publishedAt, + }) + .from(WebsiteArticles) + .where(where) + .orderBy(sort(publishedAt), sort(WebsiteArticles.id)) + .limit(perPage) + .offset((page - 1) * perPage); + return { rows, total, page, perPage, lastPage, search, order }; +}