diff --git a/src/app/(site)/search/page.tsx b/src/app/(site)/search/page.tsx index 131fa145..6015f468 100644 --- a/src/app/(site)/search/page.tsx +++ b/src/app/(site)/search/page.tsx @@ -1,200 +1,228 @@ -import { and, eq, like } from "drizzle-orm"; import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; +import { LocalEventTime } from "@/components/public/local-event-time"; +import { ContentCard, EmptyState } from "@/components/public/ui"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; -import { SurfaceCard } from "@/components/surface-card"; -import { db, Rooms, User } from "@/lib/db"; +import { loadPublicSearch } from "@/lib/services/public-search"; -export const metadata: Metadata = { - title: "Search", - description: "Search for users and rooms in the hotel.", - openGraph: { - title: "Search", - description: "Search for users and rooms in the hotel.", - type: "website", - }, +export async function generateMetadata(): Promise { + const t = await getTranslations("pages.search"); + return { title: t("title"), description: t("subtitle") }; +} +type Params = { + q?: string; + usersPage?: string; + roomsPage?: string; + newsPage?: string; + eventsPage?: string; }; - -type SearchParams = Promise<{ q?: string }>; - export default async function SearchPage({ searchParams, }: { - searchParams: SearchParams; + searchParams: Promise; }) { - const { q } = await searchParams; - const query = q?.trim(); - - if (!query) { + const t = await getTranslations("pages.search"); + const params = await searchParams; + const result = await loadPublicSearch({ + query: params.q ?? "", + usersPage: Number(params.usersPage ?? 1), + roomsPage: Number(params.roomsPage ?? 1), + newsPage: Number(params.newsPage ?? 1), + eventsPage: Number(params.eventsPage ?? 1), + }); + const query = result?.query ?? ""; + const pagination = ( + kind: "users" | "rooms" | "news" | "events", + data: { page: number; lastPage: number; total: number }, + ) => { + const href = (page: number) => + `/search?${new URLSearchParams({ ...Object.fromEntries(Object.entries(params).filter((entry): entry is [string, string] => typeof entry[1] === "string")), q: query, [`${kind}Page`]: String(page) })}#${kind}`; return ( - -

- Enter a search term to find users. -

-
+ ); - } - - const [users, rooms] = await Promise.all([ - db - .select({ - id: User.id, - username: User.username, - look: User.look, - motto: User.motto, - online: User.online, - rank: User.rank, - }) - .from(User) - .where(like(User.username, `%${query}%`)) - .limit(30) - .catch(() => []), - db - .select({ - id: Rooms.id, - name: Rooms.name, - ownerName: Rooms.ownerName, - users: Rooms.users, - usersMax: Rooms.usersMax, - }) - .from(Rooms) - .where(and(like(Rooms.name, `%${query}%`), eq(Rooms.state, "open"))) - .limit(12) - .catch(() => []), - ]); - + }; return ( -
- -
- -
-
- -

- Results for "{query}" โ€” {users.length} user - {users.length !== 1 ? "s" : ""} - {rooms.length > 0 && - `, ${rooms.length} room${rooms.length !== 1 ? "s" : ""}`} -

- - {users.length > 0 && ( - -

- Users -

-
- {users.map((u) => ( - - - - {u.username} - - - {u.online === "1" ? "๐ŸŸข Online" : "Offline"} - - - ))} -
-
+ + {!result ? ( + + {t("enterQuery")} + + ) : ( + <> +

{t("resultsFor", { query })}

+
+ + {result.users.status === "rejected" ? ( +

{t("loadError")}

+ ) : ( + <> + {result.users.value.rows.length === 0 ? ( + {t("empty")} + ) : ( +
+ {result.users.value.rows.map((user) => ( + + + + {user.username} + + + {t(user.online === "1" ? "online" : "offline")} + + + ))} +
+ )} + {pagination("users", result.users.value)} + + )} +
+
+
+ + {result.rooms.status === "rejected" ? ( +

{t("loadError")}

+ ) : ( + <> + {result.rooms.value.rows.length === 0 ? ( + {t("empty")} + ) : ( +
+ {result.rooms.value.rows.map((room) => ( + +

{room.name}

+

+ {room.ownerName} ยท {room.users}/{room.usersMax} +

+ + ))} +
+ )} + {pagination("rooms", result.rooms.value)} + + )} +
+
+
+ + {result.news.status === "rejected" ? ( +

{t("loadError")}

+ ) : ( + <> + {result.news.value.rows.length === 0 ? ( + {t("empty")} + ) : ( +
+ {result.news.value.rows.map((article) => ( + +

+ {article.title} +

+ + ))} +
+ )} + {pagination("news", result.news.value)} + + )} +
+
+
+ + {result.events.status === "rejected" ? ( +

{t("loadError")}

+ ) : ( + <> + {result.events.value.rows.length === 0 ? ( + {t("empty")} + ) : ( +
+ {result.events.value.rows.map((event) => ( + +

+ {event.title} +

+ + + ))} +
+ )} + {pagination("events", result.events.value)} + + )} +
+
+ )} - - {rooms.length > 0 && ( - -

- Rooms -

-
- {rooms.map((room) => ( - -

- {room.name} -

-

- {room.ownerName} ยท {room.users}/{room.usersMax} -

- - ))} -
-
- )} - - {users.length === 0 && rooms.length === 0 && ( - -

- No users or rooms found for "{query}". -

-
- )} -
+ ); } diff --git a/src/lib/services/news-archive.ts b/src/lib/services/news-archive.ts index 3a57ca2e..bb20486d 100644 --- a/src/lib/services/news-archive.ts +++ b/src/lib/services/news-archive.ts @@ -5,7 +5,9 @@ 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 search = (typeof options.search === "string" ? options.search : "") + .trim() + .slice(0, 191); const order = options.order === "oldest" ? "oldest" : "newest"; const publishedAt = sql`COALESCE(${WebsiteArticles.publishAt}, ${WebsiteArticles.createdAt})`.mapWith( diff --git a/src/lib/services/public-read.ts b/src/lib/services/public-read.ts new file mode 100644 index 00000000..61080729 --- /dev/null +++ b/src/lib/services/public-read.ts @@ -0,0 +1,14 @@ +import "server-only"; +import { logger } from "@/lib/logger"; + +/** Keep failed public reads distinct from valid empty collections and zero counts. */ +export function publicReadFailure(section: string) { + return (error: unknown): null => { + logger.error("Public section query failed", { + module: "public-data", + section, + error, + }); + return null; + }; +} diff --git a/src/lib/services/public-search.test.ts b/src/lib/services/public-search.test.ts new file mode 100644 index 00000000..65ba26ad --- /dev/null +++ b/src/lib/services/public-search.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + queries: [] as { sql: string; params: unknown[] }[], + failUsers: false, +})); +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) => { + state.queries.push({ sql, params }); + if (state.failUsers && sql.includes("from `users`")) + throw Error("users unavailable"); + return { rows: sql.includes("count(*)") ? [[50]] : [] }; + }), + }; +}); + +import { loadPublicSearch } from "./public-search"; + +describe("public search", () => { + beforeEach(() => { + state.queries = []; + state.failUsers = false; + }); + it("does not query for a blank term", async () => { + expect(await loadPublicSearch({ query: " " })).toBeNull(); + expect(state.queries).toEqual([]); + }); + it("searches all four categories with public visibility predicates", async () => { + const result = await loadPublicSearch({ query: "needle'", roomsPage: 2 }); + expect(result?.rooms.status).toBe("fulfilled"); + const rooms = state.queries.filter((q) => q.sql.includes("from `rooms`")); + expect(rooms[0].params).toEqual(["%needle'%", "open"]); + expect(rooms[1].params.slice(-2)).toEqual([12, 12]); + const news = state.queries.find((q) => + q.sql.includes("from `website_articles`"), + ); + expect(news?.sql).toContain("<= NOW()"); + expect(news?.params[0]).toBe("published"); + const events = state.queries.find((q) => + q.sql.includes("from `website_events`"), + ); + expect(events?.params.slice(0, 2)).toEqual(["published", "completed"]); + }); + it("preserves successful categories when one query fails", async () => { + state.failUsers = true; + const result = await loadPublicSearch({ query: "test" }); + expect(result?.users.status).toBe("rejected"); + expect(result?.rooms.status).toBe("fulfilled"); + expect(result?.news.status).toBe("fulfilled"); + expect(result?.events.status).toBe("fulfilled"); + }); + it("clamps each independent page and limits query length", async () => { + const result = await loadPublicSearch({ + query: "x".repeat(500), + usersPage: 999, + roomsPage: -1, + }); + expect(result?.query).toHaveLength(191); + expect(result?.users).toMatchObject({ + status: "fulfilled", + value: { page: 5 }, + }); + expect(result?.rooms).toMatchObject({ + status: "fulfilled", + value: { page: 1 }, + }); + }); +}); diff --git a/src/lib/services/public-search.ts b/src/lib/services/public-search.ts new file mode 100644 index 00000000..8975af61 --- /dev/null +++ b/src/lib/services/public-search.ts @@ -0,0 +1,124 @@ +import { publicReadFailure } from "@/lib/services/public-read"; +import "server-only"; +import { and, asc, count, eq, inArray, like, or } from "drizzle-orm"; +import { db, Rooms, User, WebsiteEvent } from "@/lib/db"; +import { loadNewsArchive } from "@/lib/services/news-archive"; + +function pageNumber(value: number | undefined, total: number) { + return Math.min( + Math.max(1, Math.ceil(total / 12)), + Number.isFinite(value) ? Math.max(1, Math.trunc(value ?? 1)) : 1, + ); +} +export async function loadPublicSearch(options: { + query: string; + usersPage?: number; + roomsPage?: number; + newsPage?: number; + eventsPage?: number; +}) { + const query = (typeof options.query === "string" ? options.query : "") + .trim() + .slice(0, 191); + if (!query) return null; + const userWhere = like(User.username, `%${query}%`); + const roomWhere = and( + like(Rooms.name, `%${query}%`), + eq(Rooms.state, "open"), + ); + const eventWhere = and( + inArray(WebsiteEvent.status, ["published", "completed"]), + or( + like(WebsiteEvent.title, `%${query}%`), + like(WebsiteEvent.description, `%${query}%`), + ), + ); + const [users, rooms, news, events] = await Promise.allSettled([ + (async () => { + const [{ value: total }] = await db + .select({ value: count() }) + .from(User) + .where(userWhere); + const page = pageNumber(options.usersPage, Number(total)); + const rows = await db + .select({ + id: User.id, + username: User.username, + look: User.look, + online: User.online, + }) + .from(User) + .where(userWhere) + .orderBy(asc(User.username), asc(User.id)) + .limit(12) + .offset((page - 1) * 12); + return { + rows, + total: Number(total), + page, + lastPage: Math.max(1, Math.ceil(Number(total) / 12)), + }; + })(), + (async () => { + const [{ value: total }] = await db + .select({ value: count() }) + .from(Rooms) + .where(roomWhere); + const page = pageNumber(options.roomsPage, Number(total)); + const rows = await db + .select({ + id: Rooms.id, + name: Rooms.name, + ownerName: Rooms.ownerName, + users: Rooms.users, + usersMax: Rooms.usersMax, + }) + .from(Rooms) + .where(roomWhere) + .orderBy(asc(Rooms.name), asc(Rooms.id)) + .limit(12) + .offset((page - 1) * 12); + return { + rows, + total: Number(total), + page, + lastPage: Math.max(1, Math.ceil(Number(total) / 12)), + }; + })(), + loadNewsArchive({ search: query, page: options.newsPage }), + (async () => { + const [{ value: total }] = await db + .select({ value: count() }) + .from(WebsiteEvent) + .where(eventWhere); + const page = pageNumber(options.eventsPage, Number(total)); + const rows = await db + .select({ + id: WebsiteEvent.id, + title: WebsiteEvent.title, + startsAt: WebsiteEvent.startsAt, + }) + .from(WebsiteEvent) + .where(eventWhere) + .orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id)) + .limit(12) + .offset((page - 1) * 12); + return { + rows, + total: Number(total), + page, + lastPage: Math.max(1, Math.ceil(Number(total) / 12)), + }; + })(), + ]); + for (const [section, result] of Object.entries({ + users, + rooms, + news, + events, + })) { + if (result.status === "rejected") + publicReadFailure(`search.${section}`)(result.reason); + } + return { query, users, rooms, news, events }; +}