File size: 6,670 Bytes
c7bece7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
/**
 * Typed client for the LedgerLens API.
 *
 * Every failure is normalised into `ApiRequestError` carrying the server's
 * error code, so the UI can react to `rate_limited` or `file_too_large`
 * specifically instead of showing "something went wrong".
 */

import type {
  Anomaly,
  AnomalyStatus,
  ApiError,
  AuditEntry,
  DocumentDetail,
  DocumentListResponse,
  DocumentStatusResponse,
  Health,
  Stats,
  UploadResponse,
} from "@/types/api";

export const API_BASE_URL = (
  process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:7860"
).replace(/\/$/, "");

export class ApiRequestError extends Error {
  readonly code: string;
  readonly status: number;
  readonly details: Record<string, unknown>;

  constructor(message: string, code: string, status: number, details: Record<string, unknown> = {}) {
    super(message);
    this.name = "ApiRequestError";
    this.code = code;
    this.status = status;
    this.details = details;
  }
}

function isApiError(value: unknown): value is ApiError {
  if (typeof value !== "object" || value === null || !("error" in value)) return false;
  const inner = (value as { error: unknown }).error;
  return typeof inner === "object" && inner !== null && "code" in inner && "message" in inner;
}

/* -------------------------------------------------------------------------- */
/* Cold starts                                                                 */
/* -------------------------------------------------------------------------- */

// The API and the database both run on free tiers that suspend when idle. The
// first request after a quiet spell has to wake a container *and* a serverless
// Postgres, which the host itself warns can take 50 seconds or more.
//
// That is normal operation here, not a fault — so a read that fails to connect
// is retried rather than reported. Without this, the first visitor after an idle
// period is told the service is unreachable while it is, in fact, starting up:
// the worst possible first impression, and a false one.
//
// Backoff sums to ~50 s of waiting, which covers the documented worst case.
const COLD_START_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 15_000, 20_000] as const;

// 502/503/504 are what a platform returns while it is bringing a service up.
const WAKING_STATUSES = new Set([502, 503, 504]);

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** Only replay requests that are safe to send twice. */
function isRetryable(init?: RequestInit): boolean {
  const method = (init?.method ?? "GET").toUpperCase();
  return method === "GET" || method === "HEAD";
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const retryable = isRetryable(init);
  let response: Response | null = null;
  let lastCause: unknown = null;

  for (let attempt = 0; ; attempt += 1) {
    try {
      response = await fetch(`${API_BASE_URL}${path}`, {
        ...init,
        cache: "no-store",
        headers: { Accept: "application/json", ...(init?.headers ?? {}) },
      });
      if (!retryable || !WAKING_STATUSES.has(response.status)) break;
      lastCause = `HTTP ${response.status}`;
    } catch (cause) {
      lastCause = cause;
      response = null;
    }

    const delay = COLD_START_BACKOFF_MS[attempt];
    if (!retryable || delay === undefined) break;
    await sleep(delay);
  }

  if (response === null) {
    throw new ApiRequestError(
      `Cannot reach the LedgerLens API at ${API_BASE_URL}. ` +
        `It runs on a free tier that sleeps when idle — if it was just woken, give it a moment and retry.`,
      "network_error",
      0,
      { cause: String(lastCause) },
    );
  }

  if (response.status === 204) return undefined as T;

  const payload: unknown = await response.json().catch(() => null);

  if (!response.ok) {
    if (isApiError(payload)) {
      throw new ApiRequestError(
        payload.error.message,
        payload.error.code,
        response.status,
        payload.error.details,
      );
    }
    throw new ApiRequestError(
      `Request failed with status ${response.status}.`,
      "unknown_error",
      response.status,
    );
  }

  return payload as T;
}

export const api = {
  health: () => request<Health>("/health"),

  stats: () => request<Stats>("/v1/stats"),

  uploadDocument: (file: File) => {
    const body = new FormData();
    body.append("file", file);
    return request<UploadResponse>("/v1/documents", { method: "POST", body });
  },

  documentStatus: (id: string) => request<DocumentStatusResponse>(`/v1/documents/${id}/status`),

  document: (id: string) => request<DocumentDetail>(`/v1/documents/${id}`),

  documentAudit: (id: string) => request<AuditEntry[]>(`/v1/documents/${id}/audit`),

  documents: (params: { limit?: number; offset?: number; status?: string } = {}) => {
    const query = new URLSearchParams();
    if (params.limit !== undefined) query.set("limit", String(params.limit));
    if (params.offset !== undefined) query.set("offset", String(params.offset));
    if (params.status) query.set("status", params.status);
    const suffix = query.toString() ? `?${query.toString()}` : "";
    return request<DocumentListResponse>(`/v1/documents${suffix}`);
  },

  anomalies: (params: { status?: AnomalyStatus; limit?: number } = {}) => {
    const query = new URLSearchParams();
    if (params.status) query.set("status", params.status);
    if (params.limit !== undefined) query.set("limit", String(params.limit));
    const suffix = query.toString() ? `?${query.toString()}` : "";
    return request<Anomaly[]>(`/v1/anomalies${suffix}`);
  },

  resolveAnomaly: (id: string, action: "approve" | "reject", note?: string) =>
    request<Anomaly>(`/v1/anomalies/${id}/resolve`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ action, note: note ?? null }),
    }),
};

/** Client-side pre-check so an obviously invalid file never costs a round trip. */
export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
export const ACCEPTED_TYPES = ["application/pdf", "image/png", "image/jpeg"] as const;

export function describeUploadProblem(file: File): string | null {
  if (file.size === 0) return `${file.name} is empty.`;
  if (file.size > MAX_UPLOAD_BYTES) {
    return `${file.name} is ${(file.size / 1024 / 1024).toFixed(1)} MB — the limit is 10 MB.`;
  }
  const type = file.type.split(";")[0]?.trim().toLowerCase() ?? "";
  const looksAccepted =
    (ACCEPTED_TYPES as readonly string[]).includes(type) ||
    /\.(pdf|png|jpe?g)$/i.test(file.name);
  if (!looksAccepted) return `${file.name} is not a PDF, PNG or JPEG.`;
  return null;
}