2pz7hheja: Add public discovery proxy at GET /api/discover
This commit is contained in:
@@ -6,3 +6,7 @@ PROFILES_BASE_URL=https://profiles.minnect.com
|
||||
|
||||
# Feed host: home feed, posts, likes, comments (used by later tasks).
|
||||
FEED_BASE_URL=https://feed.sharedservices.vt.com
|
||||
|
||||
# Discovery host: public, read-only, unauthenticated. Featured experts,
|
||||
# trending minnects and categories. No token is ever sent to it.
|
||||
DISCOVER_BASE_URL=https://api.minnect.com
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDiscover } from "@/lib/discover";
|
||||
|
||||
// GET /api/discover
|
||||
// Same-origin proxy for Minnect's PUBLIC discovery data. No session needed.
|
||||
// Returns { featuredExperts, trendingMinnects, categories }, public fields only.
|
||||
// Upstream failure -> 502 { error: "discover_unavailable" } (never empty arrays,
|
||||
// so the UI can tell "nothing to show" from "could not load").
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const result = await getDiscover();
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||
}
|
||||
return NextResponse.json(result.data);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,10 @@ export const FEED_BASE_URL = baseUrl(
|
||||
process.env.FEED_BASE_URL,
|
||||
"https://feed.sharedservices.vt.com",
|
||||
);
|
||||
|
||||
// Public, read-only, UNAUTHENTICATED discovery host (featured experts,
|
||||
// trending minnects, categories). Public fields only; no user token is sent.
|
||||
export const DISCOVER_BASE_URL = baseUrl(
|
||||
process.env.DISCOVER_BASE_URL,
|
||||
"https://api.minnect.com",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "@/app/api/discover/route";
|
||||
import { mapDiscover } from "@/lib/discover";
|
||||
|
||||
const expert = {
|
||||
id: 7, slug: "jane-doe", first_name: "Jane", last_name: "Doe", byline: "Coach",
|
||||
url_picture: "https://img/jane.jpg", rating: 4.9, reviews_count: 12, available: true,
|
||||
city: "Austin", country: "US",
|
||||
quick_question_rate: 25, video_call_rate: 100, is_blocking: false, score: 99,
|
||||
leaderboard: 3, profile_views: 500, status: "active",
|
||||
phone: "555-1234", dob: "1990-01-01", zip: "78701", email: "jane@x.com",
|
||||
gender: "f", stripe_account_id: "acct_1", kyc_status: "verified",
|
||||
};
|
||||
const body = {
|
||||
data: {
|
||||
featured_experts: [expert],
|
||||
trending_conversations: [{
|
||||
id: "c1", question_text: "How?", answer_text: "secret text", views: 10,
|
||||
made_public_on: "2026-01-01", thumbnail: { url: "https://img/t.jpg" },
|
||||
answer: { url: "https://a/1.mp3", duration: 42, waveform_data: [1, 2, 3], file_type: "audio" },
|
||||
expert,
|
||||
}],
|
||||
trending_categories: [
|
||||
{ id: 2, name: "Money", icon: "m", sort_order: 2, active: true, search: "x" },
|
||||
{ id: 1, name: "Health", icon: "h", sort_order: 1, active: true },
|
||||
{ id: 3, name: "Hidden", icon: "x", sort_order: 0, active: false },
|
||||
],
|
||||
popular_topics: [], wallet: { balance: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
async function withUpstream(impl: () => Promise<Response>) {
|
||||
const orig = globalThis.fetch;
|
||||
const spy = vi.fn(impl);
|
||||
globalThis.fetch = spy as unknown as typeof fetch;
|
||||
try {
|
||||
return { res: await GET(), spy };
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
}
|
||||
|
||||
describe("GET /api/discover", () => {
|
||||
it("maps public fields, sorts and filters categories, sends no auth", async () => {
|
||||
const { res, spy } = await withUpstream(async () => new Response(JSON.stringify(body)));
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.featuredExperts[0]).toEqual({
|
||||
id: 7, slug: "jane-doe", displayName: "Jane Doe", byline: "Coach",
|
||||
pictureUrl: "https://img/jane.jpg", rating: 4.9, reviewsCount: 12, available: true,
|
||||
location: { city: "Austin", country: "US" },
|
||||
});
|
||||
expect(json.trendingMinnects[0]).toEqual({
|
||||
id: "c1", question: "How?", views: 10, thumbnailUrl: "https://img/t.jpg",
|
||||
audio: { url: "https://a/1.mp3", duration: 42, waveform: [1, 2, 3] },
|
||||
expert: { slug: "jane-doe", displayName: "Jane Doe", pictureUrl: "https://img/jane.jpg", byline: "Coach", rating: 4.9 },
|
||||
});
|
||||
expect(json.categories).toEqual([
|
||||
{ id: 1, name: "Health", icon: "h", sortOrder: 1 },
|
||||
{ id: 2, name: "Money", icon: "m", sortOrder: 2 },
|
||||
]);
|
||||
const [url, init] = spy.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe("https://api.minnect.com/api/v1/individual/home");
|
||||
expect(JSON.stringify(init.headers)).not.toMatch(/authorization/i);
|
||||
});
|
||||
|
||||
it("never leaks PII, KYC, payment or internal fields", async () => {
|
||||
const { res } = await withUpstream(async () => new Response(JSON.stringify(body)));
|
||||
const text = JSON.stringify(await res.json());
|
||||
for (const bad of [
|
||||
"555-1234", "1990-01-01", "78701", "jane@x.com", "acct_1", "kyc", "stripe",
|
||||
"is_blocking", "isBlocking", "score", "leaderboard", "profile_views",
|
||||
"quick_question_rate", "video_call_rate", "secret text", "wallet", "balance",
|
||||
"gender", "phone", "dob", "zip", "email",
|
||||
]) {
|
||||
expect(text.toLowerCase()).not.toContain(bad.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it("tolerates missing fields", () => {
|
||||
const out = mapDiscover({
|
||||
data: {
|
||||
featured_experts: [{ id: 1 }, {}, null, "x"],
|
||||
trending_conversations: [{ id: 2 }, { question_text: "no id" }],
|
||||
trending_categories: [{ id: 3, name: "A" }, { id: 4 }],
|
||||
},
|
||||
});
|
||||
expect(out.featuredExperts).toEqual([{
|
||||
id: 1, slug: null, displayName: "", byline: null, pictureUrl: null, rating: null,
|
||||
reviewsCount: null, available: null, location: { city: null, country: null },
|
||||
}]);
|
||||
expect(out.trendingMinnects).toHaveLength(1);
|
||||
expect(out.trendingMinnects[0].audio).toEqual({ url: null, duration: null, waveform: null });
|
||||
expect(out.categories).toEqual([{ id: 3, name: "A", icon: null, sortOrder: 0 }]);
|
||||
expect(mapDiscover(null)).toEqual({ featuredExperts: [], trendingMinnects: [], categories: [] });
|
||||
});
|
||||
|
||||
it("upstream non-2xx -> 502", async () => {
|
||||
const { res } = await withUpstream(async () => new Response("boom", { status: 500 }));
|
||||
expect(res.status).toBe(502);
|
||||
expect(await res.json()).toEqual({ error: "discover_unavailable" });
|
||||
});
|
||||
|
||||
it("network error or bad JSON -> 502", async () => {
|
||||
const a = await withUpstream(async () => { throw new Error("down"); });
|
||||
expect(a.res.status).toBe(502);
|
||||
const b = await withUpstream(async () => new Response("<html>"));
|
||||
expect(b.res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user