164 lines
4.8 KiB
TypeScript
164 lines
4.8 KiB
TypeScript
import { DISCOVER_BASE_URL } from "./env";
|
|
|
|
// Maps Minnect's public discovery payload (GET /api/v1/individual/home) to
|
|
// PUBLIC FIELDS ONLY. The mappers build each object from an explicit
|
|
// allow-list, so phone, DOB, zip, email, gender, KYC/Stripe fields,
|
|
// is_blocking, scores and rates can never leak through. No token is sent or logged.
|
|
|
|
export interface DiscoverExpert {
|
|
id: string | number;
|
|
slug: string | null;
|
|
displayName: string;
|
|
byline: string | null;
|
|
pictureUrl: string | null;
|
|
rating: number | null;
|
|
reviewsCount: number | null;
|
|
available: boolean | null;
|
|
location: { city: string | null; country: string | null };
|
|
}
|
|
|
|
export interface DiscoverMinnect {
|
|
id: string | number;
|
|
question: string | null;
|
|
views: number | null;
|
|
thumbnailUrl: string | null;
|
|
audio: {
|
|
url: string | null;
|
|
duration: number | null;
|
|
waveform: number[] | string | null;
|
|
};
|
|
expert: {
|
|
slug: string | null;
|
|
displayName: string;
|
|
pictureUrl: string | null;
|
|
byline: string | null;
|
|
rating: number | null;
|
|
};
|
|
}
|
|
|
|
export interface DiscoverCategory {
|
|
id: string | number;
|
|
name: string;
|
|
icon: string | null;
|
|
sortOrder: number;
|
|
}
|
|
|
|
export interface DiscoverData {
|
|
featuredExperts: DiscoverExpert[];
|
|
trendingMinnects: DiscoverMinnect[];
|
|
categories: DiscoverCategory[];
|
|
}
|
|
|
|
export type DiscoverResult =
|
|
| { ok: true; data: DiscoverData }
|
|
| { ok: false; status: 502; error: "discover_unavailable" };
|
|
|
|
type Rec = Record<string, unknown>;
|
|
|
|
const rec = (v: unknown): Rec =>
|
|
v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
|
|
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
|
|
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 bool = (v: unknown): boolean | null => (typeof v === "boolean" ? v : null);
|
|
const id = (v: unknown): string | number | null =>
|
|
typeof v === "string" && v ? v : typeof v === "number" ? v : null;
|
|
|
|
function displayName(r: Rec): string {
|
|
return [str(r.first_name), str(r.last_name)].filter(Boolean).join(" ");
|
|
}
|
|
|
|
export function mapExpert(raw: unknown): DiscoverExpert | null {
|
|
const r = rec(raw);
|
|
const eid = id(r.id);
|
|
if (eid === null) return null;
|
|
return {
|
|
id: eid,
|
|
slug: str(r.slug),
|
|
displayName: displayName(r),
|
|
byline: str(r.byline),
|
|
pictureUrl: str(r.url_picture),
|
|
rating: num(r.rating),
|
|
reviewsCount: num(r.reviews_count),
|
|
available: bool(r.available),
|
|
location: { city: str(r.city), country: str(r.country) },
|
|
};
|
|
}
|
|
|
|
function waveform(v: unknown): number[] | string | null {
|
|
if (Array.isArray(v)) return v.filter((n): n is number => typeof n === "number");
|
|
return str(v);
|
|
}
|
|
|
|
export function mapMinnect(raw: unknown): DiscoverMinnect | null {
|
|
const r = rec(raw);
|
|
const mid = id(r.id);
|
|
if (mid === null) return null;
|
|
const answer = rec(r.answer);
|
|
const expert = rec(r.expert);
|
|
return {
|
|
id: mid,
|
|
question: str(r.question_text),
|
|
views: num(r.views),
|
|
thumbnailUrl: str(rec(r.thumbnail).url),
|
|
audio: {
|
|
url: str(answer.url),
|
|
duration: num(answer.duration),
|
|
waveform: waveform(answer.waveform_data),
|
|
},
|
|
expert: {
|
|
slug: str(expert.slug),
|
|
displayName: displayName(expert),
|
|
pictureUrl: str(expert.url_picture),
|
|
byline: str(expert.byline),
|
|
rating: num(expert.rating),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function mapCategory(raw: unknown): DiscoverCategory | null {
|
|
const r = rec(raw);
|
|
const cid = id(r.id);
|
|
const name = str(r.name);
|
|
if (cid === null || !name || r.active === false) return null;
|
|
return { id: cid, name, icon: str(r.icon), sortOrder: num(r.sort_order) ?? 0 };
|
|
}
|
|
|
|
export function mapDiscover(body: unknown): DiscoverData {
|
|
const data = rec(rec(body).data);
|
|
const keep = <T>(x: T | null): x is T => x !== null;
|
|
return {
|
|
featuredExperts: arr(data.featured_experts).map(mapExpert).filter(keep),
|
|
trendingMinnects: arr(data.trending_conversations).map(mapMinnect).filter(keep),
|
|
categories: arr(data.trending_categories)
|
|
.map(mapCategory)
|
|
.filter(keep)
|
|
.sort((a, b) => a.sortOrder - b.sortOrder),
|
|
};
|
|
}
|
|
|
|
const UNAVAILABLE: DiscoverResult = {
|
|
ok: false,
|
|
status: 502,
|
|
error: "discover_unavailable",
|
|
};
|
|
|
|
/** Fetch and map the public discovery data. Never throws; upstream failure -> 502. */
|
|
export async function getDiscover(
|
|
fetchImpl: typeof fetch = fetch,
|
|
): Promise<DiscoverResult> {
|
|
try {
|
|
const res = await fetchImpl(`${DISCOVER_BASE_URL}/api/v1/individual/home`, {
|
|
headers: { Accept: "application/json" },
|
|
cache: "no-store",
|
|
});
|
|
if (!res.ok) return UNAVAILABLE;
|
|
const body: unknown = await res.json();
|
|
return { ok: true, data: mapDiscover(body) };
|
|
} catch {
|
|
return UNAVAILABLE;
|
|
}
|
|
}
|