Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
import Constants from "expo-constants";
import { useAuth } from "./auth";
/**
* API-klient. Mobilappen pratar ENDAST med Food API aldrig direkt med
* modelleverantörer, och innehåller inga AI-hemligheter (spec §31, §61.13).
* 401 → automatisk refresh med roterande token → retry en gång.
*/
const API_BASE =
(Constants.expoConfig?.extra as { apiBaseUrl?: string } | undefined)?.apiBaseUrl ??
"http://localhost:4000";
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
public readonly details?: unknown,
) {
super(message);
}
}
let refreshPromise: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = (async () => {
const { getRefreshToken, setSession, logout } = useAuth.getState();
const refreshToken = await getRefreshToken();
if (!refreshToken) return false;
try {
const res = await fetch(`${API_BASE}/v1/auth/refresh`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
await logout();
return false;
}
const json = (await res.json()) as { accessToken: string; refreshToken: string };
await setSession(
{ accessToken: json.accessToken, refreshToken: json.refreshToken },
useAuth.getState().user,
);
return true;
} catch {
return false;
} finally {
setTimeout(() => {
refreshPromise = null;
}, 100);
}
})();
}
return refreshPromise;
}
export async function api<T = unknown>(
path: string,
options: { method?: string; body?: unknown; retry?: boolean } = {},
): Promise<T> {
const { accessToken } = useAuth.getState();
const res = await fetch(`${API_BASE}${path}`, {
method: options.method ?? "GET",
headers: {
"content-type": "application/json",
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
},
body: options.body != null ? JSON.stringify(options.body) : undefined,
});
if (res.status === 401 && options.retry !== false) {
const refreshed = await tryRefresh();
if (refreshed) return api<T>(path, { ...options, retry: false });
}
const json = (await res.json().catch(() => ({}))) as {
error?: { code?: string; message?: string; details?: unknown };
};
if (!res.ok) {
throw new ApiError(
res.status,
json.error?.code ?? "UNKNOWN",
json.error?.message ?? `HTTP ${res.status}`,
json.error?.details,
);
}
return json as T;
}
/** Ladda upp en bild till en presignad URL (mock-S3 i dev, riktig S3 i prod). */
export async function uploadImage(
upload: { uploadUrl: string; headers: Record<string, string> },
localUri: string,
): Promise<void> {
const blob = await (await fetch(localUri)).blob();
const res = await fetch(upload.uploadUrl, {
method: "PUT",
headers: upload.headers,
body: blob,
});
if (!res.ok) throw new ApiError(res.status, "UPLOAD_FAILED", "Bilduppladdningen misslyckades.");
}
export { API_BASE };