feat(search): search public news events users and open rooms
CI / check (push) Failing after 54s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-11 00:29:54 +02:00
1 parent 506e14783c
commit ba9c61d808
5 files changed
+424 -184

No files matched your search

+3 -1
View File
@@ -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<Date | null>`COALESCE(${WebsiteArticles.publishAt}, ${WebsiteArticles.createdAt})`.mapWith(
+14
View File
@@ -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;
};
}
+72
View File
@@ -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 },
});
});
});
+124
View File
@@ -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 };
}