bdf9cupe3: Add expert text search and category browse to the left column

This commit is contained in:
2026-10-19 17:33:43 -06:00
parent b97a77471b
commit 5b70bbfefa
7 changed files with 463 additions and 2 deletions
+30
View File
@@ -0,0 +1,30 @@
// Browser-side reader for the same-origin GET /api/experts/search route.
import type { ExpertResult } from "./experts-search";
export class ExpertSearchError extends Error {
constructor() {
super("experts_unavailable");
}
}
export async function fetchExperts(
query: { q?: string; category?: string },
fetchImpl: typeof fetch = fetch,
): Promise<ExpertResult[]> {
const params = new URLSearchParams();
if (query.q) params.set("q", query.q);
else if (query.category) params.set("category", query.category);
let res: Response;
try {
res = await fetchImpl(`/api/experts/search?${params}`, { credentials: "same-origin" });
} catch {
throw new ExpertSearchError();
}
if (!res.ok) throw new ExpertSearchError();
try {
const body = (await res.json()) as Record<string, unknown>;
return Array.isArray(body.experts) ? (body.experts as ExpertResult[]) : [];
} catch {
throw new ExpertSearchError();
}
}
+144
View File
@@ -0,0 +1,144 @@
import { DISCOVER_BASE_URL } from "./env";
// Expert-only search and category browse over Minnect's PUBLIC expert list
// (GET /api/v3/experts). Never touches /api/v4/users or any user search.
//
// Upstream findings (probed 2026-10-19, unauthenticated):
// - /api/v1/individual/experts/search?query= ignores `query` (returns all
// experts) and its payload carries KYC/payment/rate fields. NOT used.
// - /api/v3/experts?search=<text>&limit=N filters by name/byline and returns a
// lean public payload (id, names, slug, byline, picture, categories). Used.
// - /api/v3/experts has no category filter and ignores `page`. Category browse
// therefore filters the first CATEGORY_POOL experts of the public list by
// category name (documented limitation, cached in memory for a few minutes).
export interface ExpertCategory {
id: string | number;
name: string;
}
export interface ExpertResult {
id: string | number;
slug: string | null;
displayName: string;
pictureUrl: string | null;
byline: string | null;
rating: number | null;
categories: ExpertCategory[];
}
export type ExpertSearchResult =
| { ok: true; experts: ExpertResult[] }
| { ok: false; status: 502; error: "experts_unavailable" };
export const SEARCH_LIMIT = 20;
export const CATEGORY_POOL = 300;
const CACHE_MS = 5 * 60 * 1000;
type Rec = Record<string, unknown>;
const rec = (v: unknown): Rec =>
v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
const str = (v: unknown): string | null =>
typeof v === "string" && v.trim() ? v : null;
const num = (v: unknown): number | null =>
typeof v === "number" && Number.isFinite(v) ? v : null;
const id = (v: unknown): string | number | null =>
typeof v === "string" && v ? v : typeof v === "number" ? v : null;
/** Explicit allow-list: nothing outside these fields can reach the browser. */
export function mapExpertResult(raw: unknown): ExpertResult | null {
const r = rec(raw);
const eid = id(r.id);
if (eid === null) return null;
const categories: ExpertCategory[] = [];
if (Array.isArray(r.categories)) {
for (const c of r.categories) {
const cr = rec(c);
const cid = id(cr.id);
const name = str(cr.name)?.trim();
if (cid !== null && name) categories.push({ id: cid, name });
}
}
return {
id: eid,
slug: str(r.slug),
displayName: [str(r.first_name), str(r.last_name)].filter(Boolean).join(" "),
pictureUrl: str(r.url_picture),
byline: str(r.byline),
rating: num(r.rating),
categories,
};
}
export function mapExpertList(body: unknown): ExpertResult[] {
const data = rec(body).data;
if (!Array.isArray(data)) return [];
const out: ExpertResult[] = [];
for (const e of data) {
const m = mapExpertResult(e);
if (m) out.push(m);
}
return out;
}
const UNAVAILABLE: ExpertSearchResult = {
ok: false,
status: 502,
error: "experts_unavailable",
};
async function fetchList(
params: Record<string, string>,
fetchImpl: typeof fetch,
): Promise<ExpertResult[] | null> {
try {
const qs = new URLSearchParams(params).toString();
const res = await fetchImpl(`${DISCOVER_BASE_URL}/api/v3/experts?${qs}`, {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) return null;
return mapExpertList(await res.json());
} catch {
return null;
}
}
/** Free-text expert search by name/byline. Never throws. */
export async function searchExperts(
q: string,
fetchImpl: typeof fetch = fetch,
): Promise<ExpertSearchResult> {
const list = await fetchList(
{ search: q, limit: String(SEARCH_LIMIT), page: "1" },
fetchImpl,
);
return list ? { ok: true, experts: list.slice(0, SEARCH_LIMIT) } : UNAVAILABLE;
}
let pool: { at: number; experts: ExpertResult[] } | null = null;
/** Test hook: forget the cached pool. */
export function resetCategoryPool(): void {
pool = null;
}
const norm = (s: string) => s.trim().toLowerCase();
/** Experts (from the first CATEGORY_POOL of the public list) in a category, by name. */
export async function browseCategory(
category: string,
fetchImpl: typeof fetch = fetch,
now: number = Date.now(),
): Promise<ExpertSearchResult> {
if (!pool || now - pool.at > CACHE_MS) {
const list = await fetchList({ limit: String(CATEGORY_POOL), page: "1" }, fetchImpl);
if (!list) return UNAVAILABLE;
pool = { at: now, experts: list };
}
const want = norm(category);
const experts = pool.experts
.filter((e) => e.categories.some((c) => norm(c.name) === want))
.slice(0, SEARCH_LIMIT);
return { ok: true, experts };
}