banyaro/backend/static/js/api.js
rene c935d3fbd4 Teil 3: Terminvorschläge + KI-Limit-Bypass für Admins/Mods — SW by-v435, APP_VER 414
- timeutils: next_appointment_slot() parst OSM opening_hours, findet Slot
- GET /health/terminvorschlaege: fällige/überfällige Einträge (30-Tage-Horizont)
  Impfung/Tierarzt nutzen Praxis-Öffnungszeiten, Rest nächster Werktag 09:00
- Frontend: Terminvorschlags-Karten, bestätigbares Modal, legt Event an
- ki.py: Admins, Moderatoren, Media Manager bypassen CLOUD_WEEKLY_LIMIT
2026-04-26 17:08:18 +02:00

619 lines
25 KiB
JavaScript

/* ============================================================
BAN YARO — API Client
Zentraler Eingang für ALLE Backend-Kommunikation.
Kein fetch() wird außerhalb dieser Datei aufgerufen.
============================================================ */
const API = (() => {
// ----------------------------------------------------------
// Interner HTTP-Kern
// ----------------------------------------------------------
async function _request(method, path, body = null, options = {}) {
const config = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
};
if (body && !(body instanceof FormData)) {
config.body = JSON.stringify(body);
} else if (body instanceof FormData) {
delete config.headers['Content-Type']; // Browser setzt multipart/form-data
config.body = body;
}
// JWT aus localStorage als Bearer (für API-Calls die das brauchen)
const token = localStorage.getItem('by_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
let response;
try {
response = await fetch(`/api${path}`, config);
} catch (err) {
const offlineMsg = 'Kein Internet — du bist offline.';
if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000);
throw new APIError(offlineMsg, 0, 'network');
}
// 204 No Content
if (response.status === 204) return null;
let data;
try {
data = await response.json();
} catch {
data = null;
}
if (!response.ok) {
const message = data?.detail || data?.message || `Fehler ${response.status}`;
// SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück
const isOffline = response.status === 503 && message.startsWith('Offline');
if (isOffline && window.UI && UI.toast) {
UI.toast.warning('Kein Internet — du bist offline.', 4000);
}
throw new APIError(message, response.status, isOffline ? 'network' : data?.code);
}
return data;
}
// ----------------------------------------------------------
// Öffentliche HTTP-Methoden
// ----------------------------------------------------------
const get = (path) => _request('GET', path);
const post = (path, body) => _request('POST', path, body);
const put = (path, body) => _request('PUT', path, body);
const patch = (path, body) => _request('PATCH', path, body);
const del = (path) => _request('DELETE', path);
const upload = (path, form) => _request('POST', path, form); // FormData
// ----------------------------------------------------------
// AUTH
// ----------------------------------------------------------
const auth = {
login(email, password) {
return post('/auth/login', { email, password });
},
register(email, password, name, ref_code) {
const body = { email, password, name };
if (ref_code) body.ref_code = ref_code;
return post('/auth/register', body);
},
logout() {
localStorage.removeItem('by_token');
return post('/auth/logout');
},
me() {
return get('/auth/me');
},
referral: () => get('/auth/referral'),
};
// ----------------------------------------------------------
// HUNDE-PROFIL
// ----------------------------------------------------------
const dogs = {
list() { return get('/dogs'); },
get(id) { return get(`/dogs/${id}`); },
create(data) { return post('/dogs', data); },
update(id, data) { return patch(`/dogs/${id}`, data); },
delete(id) { return del(`/dogs/${id}`); },
uploadPhoto(id, formData) { return upload(`/dogs/${id}/photo`, formData); },
updatePhotoPosition(id, zoom, offsetX, offsetY) {
return patch(`/dogs/${id}/photo-position`, { zoom, offset_x: offsetX, offset_y: offsetY });
},
deletePhoto(id) { return del(`/dogs/${id}/photo`); },
getSkills(id) { return get(`/dogs/${id}/skills`); },
};
// ----------------------------------------------------------
// TAGEBUCH
// ----------------------------------------------------------
const diary = {
list(dogId, params = {}) {
const q = new URLSearchParams(params).toString();
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
},
stats(dogId) { return get(`/dogs/${dogId}/diary/stats`); },
get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); },
create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); },
update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); },
delete(dogId, id) { return del(`/dogs/${dogId}/diary/${id}`); },
uploadMedia(dogId, id, formData) {
return upload(`/dogs/${dogId}/diary/${id}/media`, formData);
},
deleteMedia(dogId, id) {
return del(`/dogs/${dogId}/diary/${id}/media`);
},
deleteMediaItem(dogId, entryId, mediaId) {
return del(`/dogs/${dogId}/diary/${entryId}/media/${mediaId}`);
},
setCover(dogId, entryId, mediaId) {
return patch(`/dogs/${dogId}/diary/${entryId}/media/${mediaId}/cover`, {});
},
nearby(dogId, lat, lon) {
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
},
locations(dogId) { return get(`/dogs/${dogId}/diary/locations`); },
calendar(dogId) { return get(`/dogs/${dogId}/diary/calendar`); },
};
// ----------------------------------------------------------
// GESUNDHEIT
// ----------------------------------------------------------
const health = {
list(dogId, typ = null) {
const q = typ ? `?typ=${encodeURIComponent(typ)}` : '';
return get(`/dogs/${dogId}/health${q}`);
},
create(dogId, data) { return post(`/dogs/${dogId}/health`, data); },
update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); },
delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); },
uploadDokument(dogId, id, formData) {
return upload(`/dogs/${dogId}/health/${id}/dokument`, formData);
},
deleteDocument(dogId, id) {
return del(`/dogs/${dogId}/health/${id}/dokument`);
},
uploadMedia(dogId, entryId, formData) {
return upload(`/dogs/${dogId}/health/${entryId}/media`, formData);
},
deleteMedia(dogId, entryId, mediaId) {
return del(`/dogs/${dogId}/health/${entryId}/media/${mediaId}`);
},
kiZusammenfassung(dogId) {
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
},
kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); },
terminvorschlaege(dogId) { return get(`/dogs/${dogId}/health/terminvorschlaege`); },
symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
},
gewichtVerlauf(dogId) {
return get(`/dogs/${dogId}/health/gewicht`);
},
};
// ----------------------------------------------------------
// TIERÄRZTE
// ----------------------------------------------------------
const tieraerzte = {
list() { return get('/tieraerzte'); },
create(data) { return post('/tieraerzte', data); },
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------
// GIFTKÖDER-ALARM
// ----------------------------------------------------------
const poison = {
listNearby(lat, lon, radius = 5000) {
return get(`/poison?lat=${lat}&lon=${lon}&radius=${radius}`);
},
report(data) { return post('/poison', data); },
confirm(id) { return post(`/poison/${id}/confirm`); },
resolve(id, data={}) { return post(`/poison/${id}/resolve`, data); },
uploadPhoto(id, form){ return upload(`/poison/${id}/photo`, form); },
};
// ----------------------------------------------------------
// KARTE & ORTE
// ----------------------------------------------------------
const places = {
list(typ = null) {
return get(`/places${typ ? '?typ=' + encodeURIComponent(typ) : ''}`);
},
listNearby(lat, lon, typ = null, radius = 5000) {
const params = new URLSearchParams({ lat, lon, radius });
if (typ) params.set('typ', typ);
return get(`/places?${params}`);
},
get(id) { return get(`/places/${id}`); },
create(data) { return post('/places', data); },
update(id, data) { return patch(`/places/${id}`, data); },
delete(id) { return del(`/places/${id}`); },
};
// ----------------------------------------------------------
// GASSI-ROUTEN
// ----------------------------------------------------------
const routes = {
list() {
return get('/routes');
},
listNearby(lat, lon, radius = 10000) {
return get(`/routes?lat=${lat}&lon=${lon}&radius=${radius}`);
},
get(id) { return get(`/routes/${id}`); },
create(data) { return post('/routes', data); },
update(id, data) { return patch(`/routes/${id}`, data); },
trim(id, gps_track) { return patch(`/routes/${id}/trim`, { gps_track }); },
feedback(id, text) { return post(`/routes/${id}/feedback`, { text }); },
elevation(id) { return get(`/routes/${id}/elevation`); },
delete(id) { return del(`/routes/${id}`); },
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); },
reverse(id) { return post(`/routes/${id}/reverse`, {}); },
addPhoto(id, file) {
const fd = new FormData();
fd.append('file', file);
return fetch(`/api/routes/${id}/photo`, {
method: 'POST', credentials: 'include', body: fd,
}).then(r => r.ok ? r.json() : Promise.reject(new Error('Foto-Upload fehlgeschlagen')));
},
};
// ----------------------------------------------------------
// TRAINING & ÜBUNGSFORTSCHRITT
// ----------------------------------------------------------
const training = {
getProgress() { return get('/training/progress'); },
setProgress(id, status) { return post('/training/progress', { exercise_id: id, status }); },
getSuggestions() { return get('/training/suggestions'); },
getPlanProgress() { return get('/training/plan-progress'); },
setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); },
getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); },
};
// ----------------------------------------------------------
// GASSI-TREFFEN
// ----------------------------------------------------------
const walks = {
list(lat = null, lon = null, radius = 20000) {
const params = new URLSearchParams({ radius });
if (lat !== null) { params.set('lat', lat); params.set('lon', lon); }
return get(`/walks?${params}`);
},
get(id) { return get(`/walks/${id}`); },
create(data) { return post('/walks', data); },
update(id, data) { return patch(`/walks/${id}`, data); },
cancel(id) { return del(`/walks/${id}`); },
join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); },
leave(id) { return del(`/walks/${id}/join`); },
nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); },
inviteCandidates(id) { return get(`/walks/${id}/invite-candidates`); },
invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); },
rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); },
participants(id) { return get(`/walks/${id}/participants`); },
};
// ----------------------------------------------------------
// EVENTS
// ----------------------------------------------------------
const events = {
list(params = {}) {
const q = new URLSearchParams(params).toString();
return get(`/events${q ? '?' + q : ''}`);
},
get(id) { return get(`/events/${id}`); },
create(data) { return post('/events', data); },
update(id, data) { return patch(`/events/${id}`, data); },
delete(id) { return del(`/events/${id}`); },
rsvp(id, status) { return post(`/events/${id}/rsvp`, { status }); },
cancelRsvp(id) { return del(`/events/${id}/rsvp`); },
listRsvp(id) { return get(`/events/${id}/rsvp`); },
};
// ----------------------------------------------------------
// SITTING
// ----------------------------------------------------------
const sitting = {
list(lat = null, lon = null, radius = 30000, service = null) {
const p = new URLSearchParams({ radius });
if (lat !== null) { p.set('lat', lat); p.set('lon', lon); }
if (service) { p.set('service', service); }
return get(`/sitting?${p}`);
},
me() { return get('/sitting/me'); },
create(data) { return post('/sitting', data); },
updateMe(data) { return patch('/sitting/me', data); },
requests() { return get('/sitting/requests'); },
inbox() { return get('/sitting/inbox'); },
sendRequest(data){ return post('/sitting/requests', data); },
updateRequest(id, status) { return patch(`/sitting/requests/${id}`, { status }); },
};
// ----------------------------------------------------------
// RATINGS
// ----------------------------------------------------------
const ratings = {
rate(targetType, targetId, stars, kommentar = null) {
return post('/ratings', { target_type: targetType, target_id: targetId, stars, kommentar });
},
list(targetType, targetId) {
return get(`/ratings/${targetType}/${targetId}`);
},
mine(targetType, targetId) {
return get(`/ratings/me/${targetType}/${targetId}`);
},
};
// ----------------------------------------------------------
// FORUM
// ----------------------------------------------------------
const forum = {
threads(params = {}) {
const q = new URLSearchParams(params).toString();
return get(`/forum/threads${q ? '?' + q : ''}`);
},
thread(id) { return get(`/forum/threads/${id}`); },
create(data) { return post('/forum/threads', data); },
deleteThread(id) { return del(`/forum/threads/${id}`); },
patchThread(id, data) { return patch(`/forum/threads/${id}`, data); },
addPost(threadId, data){ return post(`/forum/threads/${threadId}/posts`, data); },
deletePost(id) { return del(`/forum/posts/${id}`); },
updateThread(id, data) { return patch(`/forum/threads/${id}/content`, data); },
updatePost(id, data) { return patch(`/forum/posts/${id}`, data); },
uploadThreadFoto(id, file) {
const fd = new FormData(); fd.append('file', file);
return upload(`/forum/threads/${id}/fotos`, fd);
},
uploadPostFoto(id, file) {
const fd = new FormData(); fd.append('file', file);
return upload(`/forum/posts/${id}/fotos`, fd);
},
like(targetType, targetId) {
return post('/forum/like', { target_type: targetType, target_id: targetId });
},
report(targetType, targetId, grund) {
return post('/forum/report', { target_type: targetType, target_id: targetId, grund });
},
reports() { return get('/forum/reports'); },
resolveReport(id) { return patch(`/forum/reports/${id}`, { resolved: 1 }); },
membersMap() { return get('/forum/members/map'); },
setLocation(lat, lon, show) {
return patch('/forum/members/location', { lat, lon, show });
},
search(q) { return get(`/forum/search?q=${encodeURIComponent(q)}`); },
// Legacy aliases (keep old names working)
listThreads(params = {}) { return forum.threads(params); },
getThread(id) { return forum.thread(id); },
createThread(data) { return forum.create(data); },
createPost(tid, data) { return forum.addPost(tid, data); },
};
// ----------------------------------------------------------
// VERLORENER HUND
// ----------------------------------------------------------
const lost = {
list(lat = null, lon = null, radius_km = 25) {
const params = new URLSearchParams({ radius_km });
if (lat !== null) { params.set('lat', lat); params.set('lon', lon); }
return get(`/lost?${params}`);
},
report(data) { return post('/lost', data); },
uploadFoto(id, form) { return upload(`/lost/${id}/foto`, form); },
markFound(id) { return post(`/lost/${id}/found`); },
delete(id) { return del(`/lost/${id}`); },
};
// ----------------------------------------------------------
// KNIGGE
// ----------------------------------------------------------
const knigge = {
vote: (szenario_id, answer) => post('/knigge/vote', { szenario_id, answer }),
votes: (szenario_id) => get(`/knigge/votes?szenario_id=${encodeURIComponent(szenario_id)}`),
kiRat: (situation) => post('/knigge/ki-rat', { situation }),
};
// ----------------------------------------------------------
// WETTER
// ----------------------------------------------------------
const weather = {
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------
// FREUNDE
// ----------------------------------------------------------
const friends = {
list() { return get('/friends/'); },
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
activity() { return get('/friends/activity'); },
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
accept(friendshipId) { return post(`/friends/${friendshipId}/accept`, {}); },
decline(friendshipId) { return post(`/friends/${friendshipId}/decline`, {}); },
remove(friendUserId) { return del(`/friends/${friendUserId}`); },
};
// ----------------------------------------------------------
// DIREKTNACHRICHTEN
// ----------------------------------------------------------
const chat = {
conversations() { return get('/chat/conversations'); },
start(partnerId) { return post('/chat/conversations', { partner_id: partnerId }); },
messages(convId, offset=0) { return get(`/chat/conversations/${convId}?offset=${offset}`); },
send(convId, text) { return post(`/chat/conversations/${convId}/messages`, { text }); },
markRead(convId) { return post(`/chat/conversations/${convId}/read`, {}); },
deleteMessage(msgId) { return del(`/chat/messages/${msgId}`); },
uploadPhoto(convId, file) {
const fd = new FormData();
fd.append('file', file);
return upload(`/chat/conversations/${convId}/upload`, fd);
},
heartbeat() { return post('/chat/heartbeat', {}); },
};
// ----------------------------------------------------------
// PUSH NOTIFICATIONS
// ----------------------------------------------------------
const push = {
getVapidKey() { return get('/push/vapid-key'); },
subscribe(subscription) { return post('/push/subscribe', subscription); },
unsubscribe() { return del('/push/subscribe'); },
};
// ----------------------------------------------------------
// PUSH-SUBSCRIPTION HELPER
// ----------------------------------------------------------
async function subscribeToPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return null;
const { vapid_public_key } = await push.getVapidKey();
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(vapid_public_key),
});
await push.subscribe(subscription.toJSON());
return subscription;
}
function _urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return new Uint8Array([...raw].map(c => c.charCodeAt(0)));
}
// ----------------------------------------------------------
// GEOLOCATION HELPER
// ----------------------------------------------------------
function getLocation(options = {}) {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation wird nicht unterstützt.'));
return;
}
navigator.geolocation.getCurrentPosition(
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
err => reject(err),
{ timeout: 8000, maximumAge: 60000, ...options }
);
});
}
// ----------------------------------------------------------
// WEBCAL
// ----------------------------------------------------------
const webcal = {
getToken: () => get('/webcal/token'),
resetToken: () => del('/webcal/token'),
};
const sharing = {
create: (dogId, role) => post(`/dogs/${dogId}/share`, { role }),
list: (dogId) => get(`/dogs/${dogId}/shares`),
revoke: (dogId, id) => del(`/dogs/${dogId}/share/${id}`),
accept: (token) => post(`/share/accept/${token}`, {}),
info: (token) => get(`/share/info/${token}`),
};
const widget = {
snapshot: () => get('/widget/snapshot'),
};
// ----------------------------------------------------------
// NOTIFICATIONS
// ----------------------------------------------------------
const notifications = {
list() { return get('/notifications'); },
unreadCount() { return get('/notifications/unread-count'); },
badge() { return get('/notifications/badge'); },
readAll() { return patch('/notifications/read-all', {}); },
read(id) { return patch(`/notifications/${id}/read`, {}); },
delete(id) { return del(`/notifications/${id}`); },
};
// ----------------------------------------------------------
// SERVICE-ANGEBOTE (Sitting & Walks Matching)
// ----------------------------------------------------------
const services = {
list(type, lat = null, lon = null, radius = 20) {
const p = new URLSearchParams({ type, radius });
if (lat !== null) { p.set('lat', lat); p.set('lon', lon); }
return get(`/services?${p}`);
},
me() { return get('/services/me'); },
upsert(data) { return post('/services', data); },
deactivate(id) { return del(`/services/${id}`); },
};
// ----------------------------------------------------------
// GASTHUND-ZUGANG
// ----------------------------------------------------------
const sittingAccess = {
grant: (data) => post('/sitting-access', data),
revoke: (id) => del(`/sitting-access/${id}`),
my: () => get('/sitting-access/my'),
};
const importData = {
notestation(dogId, file) {
const fd = new FormData();
fd.append('dog_id', dogId);
fd.append('file', file);
return upload('/import/notestation', fd);
},
csv(dogId, file) {
const fd = new FormData();
fd.append('dog_id', dogId);
fd.append('file', file);
return upload('/import/csv', fd);
},
};
// ----------------------------------------------------------
// NOTIZEN
// ----------------------------------------------------------
const notes = {
get(parentType, parentId) {
return get(`/notes/${parentType}/${parentId}`);
},
getAll(params) {
return get('/notes?' + new URLSearchParams(params || {}).toString());
},
analyse() {
return post('/notes/ki-analyse', {});
},
create(parentType, parentId, data) {
return post(`/notes/${parentType}/${parentId}`, data);
},
update(id, data) {
return patch(`/notes/${id}`, data);
},
delete(id) {
return del(`/notes/${id}`);
},
};
// ----------------------------------------------------------
// ERROR-KLASSE
// ----------------------------------------------------------
class APIError extends Error {
constructor(message, status, code) {
super(message);
this.name = 'APIError';
this.status = status;
this.code = code;
}
}
// Lokale Gerätezeit als ISO-String ("2026-04-26T12:00:00") für server-seitige created_at
function clientNow() {
return new Date().toLocaleString('sv').replace(' ', 'T');
}
// Öffentliche API
return {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
subscribeToPush, getLocation, clientNow,
APIError,
};
})();