emrgilhzp: Proxy the home feed with cursor paging

This commit is contained in:
2026-10-19 13:58:49 -06:00
parent 4d52e52ccf
commit 20af24b25d
4 changed files with 223 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE } from "@/lib/cookie";
import { clampLimit, getHomeFeedPage } from "@/lib/feed";
// GET /api/feed?cursor=<nextCursor>&limit=<n>
// Same-origin proxy for the Minnect home feed. Returns { items, nextCursor }.
// 401 { error: "sign_in" } when the session is missing or dead.
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
const cursor = req.nextUrl.searchParams.get("cursor") || null;
const limit = clampLimit(req.nextUrl.searchParams.get("limit"));
const result = await getHomeFeedPage(sessionId, cursor, limit);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ items: result.items, nextCursor: result.nextCursor });
}
+90
View File
@@ -0,0 +1,90 @@
import { FEED_BASE_URL } from "./env";
import { ensureFreshSession, refreshSession } from "./session-refresh";
// Home-feed proxy logic. Reads one page of the person's home feed from Minnect
// with their server-held feed token. Tokens never leave the server and are
// never logged. Card-field extraction is a later task; items pass through raw.
export const DEFAULT_LIMIT = 20;
export const MAX_LIMIT = 50;
export type FeedPageResult =
| { ok: true; items: unknown[]; nextCursor: string | null }
| { ok: false; status: 401 | 502; error: "sign_in" | "feed_unavailable" };
interface FeedOptions {
fetchImpl?: typeof fetch;
now?: number;
}
/** Clamp a requested page size to 1..50, defaulting to 20. */
export function clampLimit(raw: string | number | null | undefined): number {
const n = typeof raw === "number" ? raw : Number.parseInt(raw ?? "", 10);
if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
return Math.min(Math.floor(n), MAX_LIMIT);
}
function buildUrl(cursor: string | null, limit: number): string {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
return `${FEED_BASE_URL}/api/v1/feeds/home/items?${params.toString()}`;
}
async function callFeed(
token: string,
cursor: string | null,
limit: number,
doFetch: typeof fetch,
): Promise<Response> {
return doFetch(buildUrl(cursor, limit), {
method: "GET",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
}
export async function getHomeFeedPage(
sessionId: string | undefined,
cursor: string | null,
limit: number,
options: FeedOptions = {},
): Promise<FeedPageResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const doFetch = options.fetchImpl ?? fetch;
let session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
let res: Response;
try {
res = await callFeed(session.feedAccessToken, cursor, limit, doFetch);
if (res.status === 401) {
// One refresh, then one retry. A second 401 means sign in again.
session = await refreshSession(sessionId, options);
if (!session) return signIn;
res = await callFeed(session.feedAccessToken, cursor, limit, doFetch);
if (res.status === 401) return signIn;
}
} catch {
return { ok: false, status: 502, error: "feed_unavailable" };
}
if (res.status !== 200) {
return { ok: false, status: 502, error: "feed_unavailable" };
}
let body: unknown;
try {
body = await res.json();
} catch {
return { ok: false, status: 502, error: "feed_unavailable" };
}
const root = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const meta =
root.metadata && typeof root.metadata === "object"
? (root.metadata as Record<string, unknown>)
: {};
return {
ok: true,
items: Array.isArray(root.data) ? root.data : [],
nextCursor: typeof meta.nextCursor === "string" && meta.nextCursor ? meta.nextCursor : null,
};
}