140 lines
5.1 KiB
JavaScript
140 lines
5.1 KiB
JavaScript
// Tiny fetch wrapper. Reads token + investisseurId from localStorage.
|
|
|
|
const BASE = import.meta.env.VITE_API_URL || '/api';
|
|
|
|
function authHeaders() {
|
|
const token = localStorage.getItem('cl_token');
|
|
const investisseurId = localStorage.getItem('cl_investisseur_id');
|
|
const deviceToken = localStorage.getItem('cl_device_token');
|
|
// Même clé que WorkspaceContext.jsx (KEY_ACTIVE) — miroir de X-Investisseur-Id,
|
|
// sert à flêcher côté serveur les nouvelles lignes vers le workspace actif
|
|
// (Étape 2a du retrofit workspace_id, cf. workspaceScope.js).
|
|
const workspaceId = localStorage.getItem('cl_active_workspace_id');
|
|
const h = {};
|
|
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
if (investisseurId) h['X-Investisseur-Id'] = investisseurId;
|
|
if (deviceToken) h['X-Device-Token'] = deviceToken;
|
|
if (workspaceId) h['X-Workspace-Id'] = workspaceId;
|
|
return h;
|
|
}
|
|
|
|
async function handle(res) {
|
|
if (res.status === 204) return null;
|
|
const text = await res.text();
|
|
let body;
|
|
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
|
if (!res.ok) {
|
|
const msg = (body && body.error) || res.statusText || 'Request failed';
|
|
const err = new Error(msg);
|
|
err.status = res.status;
|
|
err.code = body && body.code;
|
|
err.details = body && body.details;
|
|
throw err;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
export const api = {
|
|
get: (path, params) => {
|
|
const qs = params ? '?' + new URLSearchParams(
|
|
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
).toString() : '';
|
|
return fetch(BASE + path + qs, { headers: authHeaders(), cache: 'no-store' }).then(handle);
|
|
},
|
|
post: (path, body) =>
|
|
fetch(BASE + path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
body: JSON.stringify(body),
|
|
}).then(handle),
|
|
put: (path, body) =>
|
|
fetch(BASE + path, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
body: JSON.stringify(body),
|
|
}).then(handle),
|
|
patch: (path, body) =>
|
|
fetch(BASE + path, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
body: JSON.stringify(body),
|
|
}).then(handle),
|
|
del: (path, body) =>
|
|
fetch(BASE + path, {
|
|
method: 'DELETE',
|
|
headers: body ? { 'Content-Type': 'application/json', ...authHeaders() } : authHeaders(),
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
}).then(handle),
|
|
upload: (path, formData) =>
|
|
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
|
|
postForm: (path, formData) =>
|
|
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
|
|
/** Flux SSE (Server-Sent Events) en POST — utilisé par l'assistant IA
|
|
* (POST /api/ai/chat). EventSource ne permet pas d'en-têtes personnalisés
|
|
* (Authorization, X-Investisseur-Id) : on lit donc le flux manuellement
|
|
* via fetch + ReadableStream, en parsant les trames "event:"/"data:"
|
|
* séparées par une ligne vide, au format écrit par le backend. Générateur
|
|
* asynchrone : `for await (const {event, data} of api.stream(...))`.
|
|
*/
|
|
stream: async function* (path, body) {
|
|
const res = await fetch(BASE + path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!res.ok || !res.body) {
|
|
let msg = res.statusText || 'Requête échouée';
|
|
try {
|
|
const text = await res.text();
|
|
const parsed = text ? JSON.parse(text) : null;
|
|
if (parsed && parsed.error) msg = parsed.error;
|
|
} catch { /* corps non-JSON, on garde statusText */ }
|
|
const err = new Error(msg);
|
|
err.status = res.status;
|
|
throw err;
|
|
}
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
|
|
let sepIdx;
|
|
while ((sepIdx = buf.indexOf('\n\n')) !== -1) {
|
|
const frame = buf.slice(0, sepIdx);
|
|
buf = buf.slice(sepIdx + 2);
|
|
|
|
let event = 'message';
|
|
const dataLines = [];
|
|
for (const line of frame.split('\n')) {
|
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
|
}
|
|
if (dataLines.length === 0) continue;
|
|
|
|
let data;
|
|
try { data = JSON.parse(dataLines.join('\n')); }
|
|
catch { data = dataLines.join('\n'); }
|
|
|
|
yield { event, data };
|
|
}
|
|
}
|
|
},
|
|
|
|
blob: (path) =>
|
|
fetch(BASE + path, { headers: authHeaders(), cache: 'no-store' }).then(async res => {
|
|
if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); }
|
|
return res.blob();
|
|
}),
|
|
exportUrl: (path, params) => {
|
|
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
|
const token = localStorage.getItem('cl_token');
|
|
return BASE + path + qs + (token ? `${qs ? '&' : '?'}token=${token}` : '');
|
|
},
|
|
};
|