From 6201fb0d2830dd03ccf711bc05a88674884c8b7d Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 17:26:19 -0600 Subject: [PATCH] 2pz7hheja: Add public discovery proxy at GET /api/discover --- .env.example | 4 + src/app/api/discover/route.ts | 18 ++++ src/lib/discover.ts | 163 ++++++++++++++++++++++++++++++++++ src/lib/env.ts | 7 ++ tests/discover.test.ts | 110 +++++++++++++++++++++++ 5 files changed, 302 insertions(+) create mode 100644 src/app/api/discover/route.ts create mode 100644 src/lib/discover.ts create mode 100644 tests/discover.test.ts diff --git a/.env.example b/.env.example index 03ef410..c55a3bd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/app/api/discover/route.ts b/src/app/api/discover/route.ts new file mode 100644 index 0000000..30da477 --- /dev/null +++ b/src/app/api/discover/route.ts @@ -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); +} diff --git a/src/lib/discover.ts b/src/lib/discover.ts new file mode 100644 index 0000000..e20f5dc --- /dev/null +++ b/src/lib/discover.ts @@ -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; + +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 = (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 { + 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; + } +} diff --git a/src/lib/env.ts b/src/lib/env.ts index a632ef1..68e67ff 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -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", +); diff --git a/tests/discover.test.ts b/tests/discover.test.ts new file mode 100644 index 0000000..926c2c7 --- /dev/null +++ b/tests/discover.test.ts @@ -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) { + 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("")); + expect(b.res.status).toBe(502); + }); +});