91 lines
3.0 KiB
TypeScript
91 lines
3.0 KiB
TypeScript
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,
|
|
};
|
|
}
|