← back to Govarbitrage

apps/mobile/lib/api.ts

146 lines

/**
 * GovArbitrage typed API client.
 *
 * Auth is OPTIONAL. The default server's read API is public, so a fresh install
 * sends NO Authorization header and gets the same data any browser or curl gets
 * — identical behavior across every client. A header is only added when the user
 * takes an explicit, visible action:
 *   • Sign in with Apple  → Authorization: Bearer <app session JWT>
 *   • Enter a username + password in Settings (to point the app at their own
 *     self-hosted server) → Authorization: Basic base64(user:pass)
 * With neither, requests are anonymous.
 */
import { loadSettings, loadAppleAccount } from "./settings";
import { authHeadersFor } from "./auth-headers";
import type {
  ListingDetail,
  ListingsQueryParams,
  ListingsResponse,
} from "./types";
import { normalizeListingsResponse, normalizeListingDetail } from "./validate";
import { ApiError } from "./api-error";

// ── Internal helpers ──────────────────────────────────────────────────────────

async function getAuthHeaders(): Promise<HeadersInit> {
  const account = await loadAppleAccount();
  const { username, password } = await loadSettings();
  return authHeadersFor({ appleToken: account?.token, username, password });
}

interface FetchOptions {
  signal?: AbortSignal;
}

const REQUEST_TIMEOUT_MS = 15_000;

async function apiFetch<T>(path: string, opts: FetchOptions = {}): Promise<T> {
  const { baseUrl } = await loadSettings();
  const authHeaders = await getAuthHeaders();

  const url = `${baseUrl.replace(/\/$/, "")}${path}`;

  // Bound every request so a stalled connection can't leave the UI spinning
  // forever. A timeout throws a real ApiError (surfaced to the user); a genuine
  // caller-abort keeps its AbortError (callers treat that as silent navigation).
  const controller = new AbortController();
  let timedOut = false;
  const timer = setTimeout(() => {
    timedOut = true;
    controller.abort();
  }, REQUEST_TIMEOUT_MS);
  if (opts.signal) {
    if (opts.signal.aborted) controller.abort();
    else opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
  }

  try {
    const res = await fetch(url, {
      headers: {
        Accept: "application/json",
        ...authHeaders,
      },
      signal: controller.signal,
    });

    if (!res.ok) {
      const body = await res.text().catch(() => "");
      const msg = body || res.statusText || String(res.status);
      throw new ApiError(res.status, msg);
    }

    return (await res.json()) as T;
  } catch (err) {
    if (timedOut) {
      throw new ApiError(0, "Request timed out. Check your connection or the server URL in Settings.");
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

// ── Error type ────────────────────────────────────────────────────────────────
// Re-exported from ./api-error so existing `import { ApiError } from "./api"`
// callers keep working while validate.ts imports it without a circular dep.
export { ApiError } from "./api-error";

// ── Listings ──────────────────────────────────────────────────────────────────

export async function fetchListings(
  params: ListingsQueryParams = {},
  opts: FetchOptions = {}
): Promise<ListingsResponse> {
  const qs = new URLSearchParams();
  if (params.search) qs.set("search", params.search);
  if (params.source) qs.set("source", params.source);
  if (params.category) qs.set("category", params.category);
  if (params.condition) qs.set("condition", params.condition);
  if (params.risk) qs.set("risk", params.risk);
  if (params.closingWithinHours != null)
    qs.set("closingWithinHours", String(params.closingWithinHours));
  if (params.sort) qs.set("sort", params.sort);
  if (params.dir) qs.set("dir", params.dir);
  if (params.page != null) qs.set("page", String(params.page));
  if (params.pageSize != null) qs.set("pageSize", String(params.pageSize));
  if (params.profile) qs.set("profile", params.profile);

  const query = qs.toString();
  const raw = await apiFetch<unknown>(
    `/api/listings${query ? `?${query}` : ""}`,
    opts
  );
  return normalizeListingsResponse(raw);
}

export async function fetchListing(
  id: string,
  opts: FetchOptions = {}
): Promise<ListingDetail> {
  const raw = await apiFetch<unknown>(`/api/listings/${encodeURIComponent(id)}`, opts);
  return normalizeListingDetail(raw);
}

// ── Connection test ───────────────────────────────────────────────────────────

export interface ConnectionTestResult {
  ok: boolean;
  latencyMs: number;
  tier?: string;
  error?: string;
}

export async function testConnection(): Promise<ConnectionTestResult> {
  const t0 = Date.now();
  try {
    const data = await fetchListings({ pageSize: 1 });
    return { ok: true, latencyMs: Date.now() - t0, tier: data.tier };
  } catch (err) {
    return {
      ok: false,
      latencyMs: Date.now() - t0,
      error: err instanceof Error ? err.message : String(err),
    };
  }
}