feat(search): search public news events users and open rooms
This commit is contained in:
1 parent
506e14783c
commit
ba9c61d808
5 files changed
+424
-184
No files matched your search
+211
-183
@@ -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<Metadata> {
|
||||
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<Params>;
|
||||
}) {
|
||||
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 (
|
||||
<SurfaceCard className="p-10 text-center">
|
||||
<p
|
||||
className="text-sm font-bold"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Enter a search term to find users.
|
||||
</p>
|
||||
</SurfaceCard>
|
||||
<nav
|
||||
aria-label={t("pagination", { category: t(kind) })}
|
||||
className="mt-4 flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
{data.page > 1 ? (
|
||||
<Link href={href(data.page - 1)} className="btn">
|
||||
{t("previous")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{t("pageSummary", {
|
||||
page: data.page,
|
||||
pages: data.lastPage,
|
||||
total: data.total,
|
||||
})}
|
||||
</span>
|
||||
{data.page < data.lastPage ? (
|
||||
<Link href={href(data.page + 1)} className="btn">
|
||||
{t("next")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SurfaceCard className="p-4">
|
||||
<form action="/search" method="GET" className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
placeholder="Search users or rooms..."
|
||||
className="flex-1 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl px-6 py-2.5 font-extrabold text-sm transition-all duration-200 hover:scale-[1.02] active:scale-95"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
}}
|
||||
>
|
||||
Search
|
||||
<main className="page-grid">
|
||||
<ContentCard title={t("title")} subtitle={t("subtitle")} icon="🔎" />
|
||||
<ContentCard>
|
||||
<form
|
||||
action="/search"
|
||||
method="GET"
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{t("queryLabel")}
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
maxLength={191}
|
||||
className="input w-full"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("submit")}
|
||||
</button>
|
||||
</form>
|
||||
</SurfaceCard>
|
||||
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Results for "{query}" — {users.length} user
|
||||
{users.length !== 1 ? "s" : ""}
|
||||
{rooms.length > 0 &&
|
||||
`, ${rooms.length} room${rooms.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
|
||||
{users.length > 0 && (
|
||||
<SurfaceCard className="p-5">
|
||||
<h2
|
||||
className="text-lg font-black tracking-tight mb-4"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Users
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||
{users.map((u) => (
|
||||
<Link
|
||||
key={u.id}
|
||||
href={`/u/${u.username}`}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all duration-200 hover:-translate-y-0.5"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt={u.username}
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-extrabold truncate max-w-full text-center"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] font-semibold"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{u.online === "1" ? "🟢 Online" : "Offline"}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</ContentCard>
|
||||
{!result ? (
|
||||
<ContentCard>
|
||||
<EmptyState>{t("enterQuery")}</EmptyState>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<>
|
||||
<p>{t("resultsFor", { query })}</p>
|
||||
<section id="users">
|
||||
<ContentCard title={t("users")}>
|
||||
{result.users.status === "rejected" ? (
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
) : (
|
||||
<>
|
||||
{result.users.value.rows.length === 0 ? (
|
||||
<EmptyState>{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-5">
|
||||
{result.users.value.rows.map((user) => (
|
||||
<Link
|
||||
key={user.id}
|
||||
href={`/u/${encodeURIComponent(user.username)}`}
|
||||
className="flex min-w-0 flex-col items-center gap-2 rounded-xl p-3"
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={user.look}
|
||||
alt={user.username}
|
||||
options={{ direction: 2 }}
|
||||
/>
|
||||
<span className="max-w-full truncate font-bold">
|
||||
{user.username}
|
||||
</span>
|
||||
<span className="text-xs muted">
|
||||
{t(user.online === "1" ? "online" : "offline")}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pagination("users", result.users.value)}
|
||||
</>
|
||||
)}
|
||||
</ContentCard>
|
||||
</section>
|
||||
<section id="rooms">
|
||||
<ContentCard title={t("rooms")}>
|
||||
{result.rooms.status === "rejected" ? (
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
) : (
|
||||
<>
|
||||
{result.rooms.value.rows.length === 0 ? (
|
||||
<EmptyState>{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{result.rooms.value.rows.map((room) => (
|
||||
<Link
|
||||
key={room.id}
|
||||
href={`/room/${room.id}`}
|
||||
className="card hover p-3"
|
||||
>
|
||||
<h3 className="break-words font-bold">{room.name}</h3>
|
||||
<p className="text-sm muted">
|
||||
{room.ownerName} · {room.users}/{room.usersMax}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pagination("rooms", result.rooms.value)}
|
||||
</>
|
||||
)}
|
||||
</ContentCard>
|
||||
</section>
|
||||
<section id="news">
|
||||
<ContentCard title={t("news")}>
|
||||
{result.news.status === "rejected" ? (
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
) : (
|
||||
<>
|
||||
{result.news.value.rows.length === 0 ? (
|
||||
<EmptyState>{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{result.news.value.rows.map((article) => (
|
||||
<Link
|
||||
key={article.slug}
|
||||
href={`/news/${article.slug}`}
|
||||
className="card hover p-3"
|
||||
>
|
||||
<h3 className="break-words font-bold">
|
||||
{article.title}
|
||||
</h3>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pagination("news", result.news.value)}
|
||||
</>
|
||||
)}
|
||||
</ContentCard>
|
||||
</section>
|
||||
<section id="events">
|
||||
<ContentCard title={t("events")}>
|
||||
{result.events.status === "rejected" ? (
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
) : (
|
||||
<>
|
||||
{result.events.value.rows.length === 0 ? (
|
||||
<EmptyState>{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{result.events.value.rows.map((event) => (
|
||||
<Link
|
||||
key={event.id}
|
||||
href={`/events/${event.id}`}
|
||||
className="card hover p-3"
|
||||
>
|
||||
<h3 className="break-words font-bold">
|
||||
{event.title}
|
||||
</h3>
|
||||
<LocalEventTime
|
||||
value={event.startsAt.toISOString()}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pagination("events", result.events.value)}
|
||||
</>
|
||||
)}
|
||||
</ContentCard>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{rooms.length > 0 && (
|
||||
<SurfaceCard className="p-5">
|
||||
<h2
|
||||
className="text-lg font-black tracking-tight mb-4"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Rooms
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{rooms.map((room) => (
|
||||
<Link
|
||||
key={room.id}
|
||||
href={`/room/${room.id}`}
|
||||
className="rounded-xl border p-3 transition-all duration-200 hover:-translate-y-0.5"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-extrabold truncate"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{room.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] font-semibold mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{room.ownerName} · {room.users}/{room.usersMax}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{users.length === 0 && rooms.length === 0 && (
|
||||
<SurfaceCard className="p-10 text-center">
|
||||
<p
|
||||
className="text-sm font-bold"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
No users or rooms found for "{query}".
|
||||
</p>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user