sw-fe/src/api/client.js

786 lines
23 KiB
JavaScript

/**
* SocioWire frontend API client
*/
import { loadAuth } from "../auth/authStorage";
function apiBase() {
const raw =
(import.meta?.env?.VITE_API_BASE_URL || "")
.toString()
.replace(/\/+$/, "");
if (raw) return raw + "/api";
try {
if (typeof window !== "undefined") {
const host = window.location.hostname || "";
if (host.startsWith("www.")) {
return `https://${host.replace(/^www\./, "")}/api`;
}
}
} catch {}
return "/api";
}
function authHeaders() {
try {
const auth = loadAuth();
const token = auth?.access_token;
if (!token) return {};
return { Authorization: `Bearer ${token}` };
} catch {
return {};
}
}
const responseCache = new Map();
function cacheKeyFor(url, headers = {}) {
const auth = headers?.Authorization || headers?.authorization;
if (auth) return ""; // avoid caching authenticated responses
return `GET:${url}`;
}
function cloneJson(value) {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
}
async function fetchJsonCached(url, { ttlMs = 0, headers = {}, credentials = "include" } = {}) {
const key = cacheKeyFor(url, headers);
if (ttlMs > 0 && key) {
const hit = responseCache.get(key);
if (hit && hit.expiresAt > Date.now()) {
return cloneJson(hit.value);
}
}
const res = await fetch(url, { credentials, headers });
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(text || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
const data = await res.json().catch(() => null);
if (ttlMs > 0 && key) {
responseCache.set(key, { value: data, expiresAt: Date.now() + ttlMs });
}
return cloneJson(data);
}
function isAssetRef(value) {
return typeof value === "string" && value.startsWith("asset://");
}
async function resolveAssetURLs(ids) {
if (!ids || !ids.length) return {};
const qs = new URLSearchParams();
qs.set("ids", ids.join(","));
try {
const res = await fetch(`${apiBase()}/assets/resolve?${qs.toString()}`, {
credentials: "include",
headers: { ...authHeaders() },
});
if (!res.ok) return {};
const data = await res.json();
return (data && data.urls) || {};
} catch (err) {
console.warn("resolveAssetURLs error:", err);
return {};
}
}
async function resolveAssetRef(value) {
if (!isAssetRef(value)) return value;
const id = value.replace("asset://", "");
const resolved = await resolveAssetURLs([id]);
return resolved[id] || value;
}
export { resolveAssetRef };
async function resolveAssetRefsInPosts(posts) {
if (!Array.isArray(posts) || posts.length === 0) return posts;
const ids = [];
for (const p of posts) {
[p?.image_small, p?.image_medium, p?.image_large, p?.image, p?.media_url].forEach((v) => {
if (isAssetRef(v)) ids.push(v.replace("asset://", ""));
});
if (Array.isArray(p?.media_urls)) {
p.media_urls.forEach((v) => {
if (isAssetRef(v)) ids.push(v.replace("asset://", ""));
});
}
}
const uniq = Array.from(new Set(ids));
if (!uniq.length) return posts;
const resolved = await resolveAssetURLs(uniq);
return posts.map((p) => {
const clone = { ...p };
if (clone.post_id && Number(clone.post_id) > 0) {
if (!clone.id || Number(clone.id) !== Number(clone.post_id)) {
clone.reco_id = clone.id;
clone.id = clone.post_id;
}
}
["image_small", "image_medium", "image_large", "image", "media_url"].forEach((k) => {
const v = clone[k];
if (isAssetRef(v)) {
const id = v.replace("asset://", "");
if (resolved[id]) clone[k] = resolved[id];
}
});
if (Array.isArray(clone.media_urls)) {
clone.media_urls = clone.media_urls.map((value) => {
if (isAssetRef(value)) {
const id = value.replace("asset://", "");
return resolved[id] || value;
}
return value;
});
}
return clone;
});
}
async function resolveAssetRefsInIslands(islands) {
if (!Array.isArray(islands) || islands.length === 0) return islands;
const ids = [];
for (const i of islands) {
if (isAssetRef(i?.avatar_url)) ids.push(i.avatar_url.replace("asset://", ""));
}
const uniq = Array.from(new Set(ids));
if (!uniq.length) return islands;
const resolved = await resolveAssetURLs(uniq);
return islands.map((i) => {
if (isAssetRef(i?.avatar_url)) {
const id = i.avatar_url.replace("asset://", "");
if (resolved[id]) return { ...i, avatar_url: resolved[id] };
}
return i;
});
}
/**
* Récupère la liste des posts, avec filtres optionnels.
*/
export async function fetchPosts(filters = {}) {
const params = new URLSearchParams();
if (filters.category) params.set("category", filters.category);
if (filters.subCategory) params.set("sub_category", filters.subCategory);
if (filters.time) params.set("time", filters.time);
if (filters.username) params.set("username", filters.username);
// Validate and set lat/lon with proper bounds checking
if (typeof filters.lat === "number") {
if (filters.lat >= -90 && filters.lat <= 90) {
params.set("lat", String(filters.lat));
} else {
console.warn("Invalid latitude value (must be -90 to 90):", filters.lat);
}
}
if (typeof filters.lon === "number") {
if (filters.lon >= -180 && filters.lon <= 180) {
params.set("lon", String(filters.lon));
} else {
console.warn("Invalid longitude value (must be -180 to 180):", filters.lon);
}
}
if (typeof filters.radiusKm === "number") {
if (filters.radiusKm > 0 && filters.radiusKm <= 20000) {
params.set("radius_km", String(filters.radiusKm));
} else {
console.warn("Invalid radius value (must be > 0 and <= 20000 km):", filters.radiusKm);
}
}
if (typeof filters.limit === "number" && filters.limit > 0) {
params.set("limit", String(filters.limit));
}
const qs = params.toString();
const url = qs ? `${apiBase()}/posts?${qs}` : `${apiBase()}/posts`;
try {
const data = await fetchJsonCached(url, { ttlMs: 15000 });
const items = Array.isArray(data) ? data : data?.items || [];
return await resolveAssetRefsInPosts(items);
} catch (err) {
console.warn("fetchPosts failed (treated as empty)", err?.status || err);
return [];
}
}
/**
* Récupère la position approx de l'utilisateur depuis le backend.
*/
export async function fetchIpLocation() {
try {
const data = await fetchJsonCached(`${apiBase()}/ip-location`, { ttlMs: 10 * 60 * 1000 });
if (!data || data === "null") return null;
const lat = data.lat;
const lon = data.lon;
if (
typeof lat === "number" &&
typeof lon === "number" &&
!Number.isNaN(lat) &&
!Number.isNaN(lon)
) {
return [lon, lat]; // [lng, lat]
}
console.warn("fetchIpLocation: invalid payload", data);
return null;
} catch (err) {
console.warn("fetchIpLocation: network error", err);
return null;
}
}
/**
* Crée un nouveau post
* token: access token Keycloak (string) ou null
*/
export async function createPost(payload, token) {
const headers = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${apiBase()}/post`, {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
let json = null;
try { json = text ? JSON.parse(text) : null; } catch {}
const errMsg =
(json && (json.message || json.error_description || json.error)) ||
(text && text.slice(0, 300)) ||
`HTTP ${res.status}`;
const err = new Error(errMsg);
err.status = res.status;
err.body = text;
// Common backend shapes
err.code =
(json && (json.code || json.error)) ||
(res.status === 403 && /email[_\s-]*not[_\s-]*verified/i.test(text || "")
? "email_not_verified"
: "") ||
"";
throw err;
}
return res.json().catch(() => ({}));
}
/**
* Fetch link preview data for a URL (auth required).
*/
export async function fetchPostPreview(url, token) {
const raw = (url || "").toString().trim();
if (!raw) return null;
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
else Object.assign(headers, authHeaders());
const res = await fetch(`${apiBase()}/post/preview`, {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify({ url: raw }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(text || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json().catch(() => null);
}
/**
* Récupère un post par ID (détails + counts)
*/
export async function fetchPostById(postId) {
if (!postId) return null;
const qs = new URLSearchParams();
qs.set("id", String(postId));
try {
const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 });
if (!data || typeof data !== "object") return null;
const resolved = await resolveAssetRefsInPosts([data]);
return resolved && resolved[0] ? resolved[0] : data;
} catch {
return null;
}
}
export async function fetchPostByUrl(url) {
const raw = (url || "").toString().trim();
if (!raw) return null;
const qs = new URLSearchParams();
qs.set("url", raw);
try {
const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 });
if (!data || typeof data !== "object") return null;
const resolved = await resolveAssetRefsInPosts([data]);
return resolved && resolved[0] ? resolved[0] : data;
} catch {
return null;
}
}
export async function fetchPostTruth(postId) {
if (!postId) return null;
const qs = new URLSearchParams();
qs.set("post_id", String(postId));
try {
const res = await fetch(`${apiBase()}/post/truth?${qs.toString()}`, {
credentials: "include",
headers: { ...authHeaders() },
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
return data?.truth || null;
} catch {
return null;
}
}
export async function votePostTruth(postId, vote) {
if (!postId) throw new Error("post_id required");
const body = JSON.stringify({ post_id: postId, vote });
const res = await fetch(`${apiBase()}/post/truth-vote`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
credentials: "include",
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "truth vote failed");
}
const data = await res.json().catch(() => ({}));
return data?.truth || null;
}
export async function updatePostVisibility(postId, payload = {}, token) {
if (!postId) throw new Error("post_id required");
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const body = JSON.stringify({ post_id: postId, ...payload });
const res = await fetch(`${apiBase()}/post/visibility`, {
method: "PATCH",
headers,
credentials: "include",
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`);
}
return res.json().catch(() => ({}));
}
/**
* Crée un nouvel utilisateur via le backend
* (backend: POST /api/signup)
* payload: { username, email, password, firstName, lastName }
*/
export async function registerUser(payload) {
const res = await fetch(`${apiBase()}/signup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("registerUser failed", res.status, text);
throw new Error(text || "Failed to register user");
}
return res.json().catch(() => ({}));
}
/**
* NEW: Fetch intelligent personalized feed with screen-aware prioritization
* Uses backend /api/smart-feed endpoint (which proxies to reco-service)
*/
export async function fetchSmartFeed(params = {}) {
const qs = new URLSearchParams();
if (params.user_id) qs.set("user_id", String(params.user_id));
if (params.username) qs.set("username", params.username);
if (typeof params.lat === "number") qs.set("lat", String(params.lat));
if (typeof params.lon === "number") qs.set("lon", String(params.lon));
if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km));
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
if (typeof params.zoom === "number") qs.set("zoom", String(params.zoom));
if (params.category) qs.set("category", String(params.category));
if (params.sub_category) qs.set("sub_category", String(params.sub_category));
if (params.time_hours) qs.set("time_hours", String(params.time_hours));
// NEW: Screen bounds for on-screen prioritization
if (params.bounds) {
const { minLat, minLon, maxLat, maxLon } = params.bounds;
if (minLat != null) qs.set("minLat", String(minLat));
if (minLon != null) qs.set("minLon", String(minLon));
if (maxLat != null) qs.set("maxLat", String(maxLat));
if (maxLon != null) qs.set("maxLon", String(maxLon));
}
const url = `${apiBase()}/smart-feed?${qs.toString()}`;
try {
const data = await fetchJsonCached(url, { ttlMs: 10000 });
const items = Array.isArray(data) ? data : data.results || data.items || [];
return await resolveAssetRefsInPosts(items);
} catch (err) {
console.warn("fetchSmartFeed error:", err);
return [];
}
}
/**
* NEW: Unified search across posts, profiles, and places
* Uses backend /api/unified-search endpoint (which proxies to reco-service)
*/
export async function fetchUnifiedSearch(query, params = {}) {
const qs = new URLSearchParams();
qs.set("q", query);
if (params.type) qs.set("type", params.type); // all|posts|profiles|places
if (typeof params.lat === "number") qs.set("lat", String(params.lat));
if (typeof params.lon === "number") qs.set("lon", String(params.lon));
if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km));
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
const url = `${apiBase()}/unified-search?${qs.toString()}`;
try {
const data = await fetchJsonCached(url, { ttlMs: 15000 });
if (data && typeof data === "object" && !Array.isArray(data)) {
if (Array.isArray(data.posts)) {
data.posts = await resolveAssetRefsInPosts(data.posts);
}
return data;
}
return { posts: [], profiles: [], places: [] };
} catch (err) {
console.warn("fetchUnifiedSearch error:", err);
return { posts: [], profiles: [], places: [] };
}
}
/**
* NEW: Poll for new posts (socket.io compatible)
* Uses reco-service /api/poll endpoint
*/
export async function pollNewPosts(params = {}) {
const qs = new URLSearchParams();
if (typeof params.since_id === "number") qs.set("since_id", String(params.since_id));
if (typeof params.lat === "number") qs.set("lat", String(params.lat));
if (typeof params.lon === "number") qs.set("lon", String(params.lon));
if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km));
if (params.category) qs.set("category", params.category);
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
const url = `${apiBase()}/poll?${qs.toString()}`;
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
console.warn("pollNewPosts failed", res.status);
return { posts: [], count: 0, max_id: 0 };
}
const data = await res.json();
if (data && Array.isArray(data.posts)) {
data.posts = await resolveAssetRefsInPosts(data.posts);
}
return data;
} catch (err) {
console.warn("pollNewPosts error:", err);
return { posts: [], count: 0, max_id: 0 };
}
}
/**
* ISLAND SERVICE: Fetch islands in viewport
* Queries island-service microservice (Go) for user islands
*
* NOTE: Currently using MOCK DATA for testing
* Replace with real API call when island-service is ready
*/
export async function fetchIslands(params = {}) {
const qs = new URLSearchParams();
if (typeof params.lat === "number") qs.set("lat", String(params.lat));
if (typeof params.lon === "number") qs.set("lon", String(params.lon));
if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km));
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
const url = `${apiBase()}/islands/nearby?${qs.toString()}`;
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
console.warn("fetchIslands failed", res.status);
return [];
}
const data = await res.json();
const items = Array.isArray(data) ? data : data.islands || [];
return await resolveAssetRefsInIslands(items);
} catch (err) {
console.warn("fetchIslands error:", err);
return [];
}
}
/**
* ISLAND SERVICE: Fetch single island details
* Gets full island data including content (posts, apps)
*
* NOTE: Currently using MOCK DATA for testing
*/
export async function fetchIsland(islandId) {
if (!islandId) return null;
const url = `${apiBase()}/islands/${encodeURIComponent(islandId)}`;
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
console.warn("fetchIsland failed", res.status);
return null;
}
const data = await res.json();
if (data && data.avatar_url && isAssetRef(data.avatar_url)) {
const resolved = await resolveAssetRefsInIslands([data]);
return resolved[0] || data;
}
return data;
} catch (err) {
console.warn("fetchIsland error:", err);
return null;
}
}
export async function fetchIslandByUsername(username) {
if (!username) return null;
const url = `${apiBase()}/islands/by-username?username=${encodeURIComponent(username)}`;
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
console.warn("fetchIslandByUsername failed", res.status);
return null;
}
const data = await res.json();
if (data && data.avatar_url && isAssetRef(data.avatar_url)) {
const resolved = await resolveAssetRefsInIslands([data]);
return resolved[0] || data;
}
return data;
} catch (err) {
console.warn("fetchIslandByUsername error:", err);
return null;
}
}
export async function uploadProfileAvatar(file, opts = {}) {
if (!file) return { ok: false };
const url = `${apiBase()}/profile/avatar`;
const form = new FormData();
form.append("file", file);
if (opts.visibility) form.append("visibility", opts.visibility);
if (opts.group_id) form.append("group_id", opts.group_id);
if (opts.users && opts.users.length) form.append("users", opts.users.join(","));
try {
const res = await fetch(url, {
method: "POST",
body: form,
credentials: "include",
headers: { ...authHeaders() },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
return { ok: false, status: res.status, text };
}
const out = await res.json();
if (out && out.avatar_url) {
out.avatar_url = await resolveAssetRef(out.avatar_url);
}
return out;
} catch (err) {
console.warn("uploadProfileAvatar error:", err);
return { ok: false };
}
}
export async function updateProfileUsername(username) {
const url = `${apiBase()}/profile/username`;
try {
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ username }),
});
const text = await res.text().catch(() => "");
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: res.ok, status: res.status, json, text };
} catch (err) {
console.warn("updateProfileUsername error:", err);
return { ok: false, status: 0, json: null, text: err?.message || "error" };
}
}
export async function fetchProfile(username = "") {
const qs = new URLSearchParams();
if (username) qs.set("username", username);
const url = qs.toString() ? `${apiBase()}/profile?${qs}` : `${apiBase()}/profile`;
try {
const res = await fetch(url, {
credentials: "include",
headers: { ...authHeaders() },
});
if (!res.ok) {
return null;
}
const data = await res.json();
if (data && data.avatar_url) {
data.avatar_url = await resolveAssetRef(data.avatar_url);
}
return data;
} catch (err) {
console.warn("fetchProfile error:", err);
return null;
}
}
export async function recordPostView(postId) {
if (!postId) return { ok: false };
try {
const res = await fetch(`${apiBase()}/post/view`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ post_id: postId }),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false };
}
}
export async function togglePostLike(postId, like = true) {
if (!postId) return { ok: false };
try {
const res = await fetch(`${apiBase()}/post/like`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
credentials: "include",
body: JSON.stringify({ post_id: postId, like }),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false };
}
}
export async function recordPostShare(postId) {
if (!postId) return { ok: false };
try {
const res = await fetch(`${apiBase()}/post/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ post_id: postId }),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false };
}
}
export async function fetchPostComments(postId, limit = 20) {
if (!postId) return [];
const qs = new URLSearchParams();
qs.set("post_id", String(postId));
if (limit) qs.set("limit", String(limit));
try {
const res = await fetch(`${apiBase()}/post/comments?${qs.toString()}`, {
credentials: "include",
});
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export async function createPostComment(postId, body) {
if (!postId || !body) return { ok: false };
try {
const res = await fetch(`${apiBase()}/post/comment`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
credentials: "include",
body: JSON.stringify({ post_id: postId, body }),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false };
}
}
export async function fetchPostLikeState(postId) {
if (!postId) return { ok: false };
const qs = new URLSearchParams();
qs.set("post_id", String(postId));
try {
const res = await fetch(`${apiBase()}/post/like-state?${qs.toString()}`, {
credentials: "include",
headers: { ...authHeaders() },
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false };
}
}