Feature: Gasthund-Zugang für Sitter
- sitting_subscriptions Tabelle (dog_id, owner_id, sitter_id, valid_until) - POST/DELETE/GET /api/sitting-access — Zugang gewähren/widerrufen/auflisten - GET /api/dogs gibt Gasthunde zurück (is_guest=True, sitting_until, owner_name) - Diary POST erlaubt Sitter-Schreibzugang; PATCH/DELETE nur für Besitzer - Dog-Switcher: GAST-Badge bei fremden Hunden - Dog-Profil: Sitter-Zugang-Sektion (nur für Besitzer), Freund auswählen + Datum - Diary Detail-View: Bearbeiten-Button für Gasthunde ausgeblendet
This commit is contained in:
parent
eef787cc72
commit
289158b2cd
10 changed files with 327 additions and 18 deletions
|
|
@ -803,6 +803,22 @@ def _migrate(conn_factory):
|
||||||
""")
|
""")
|
||||||
logger.info("Migration: health_media Tabelle bereit.")
|
logger.info("Migration: health_media Tabelle bereit.")
|
||||||
|
|
||||||
|
# Gasthund-Zugang: Sitter darf temporär für Hund schreiben
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sitting_subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
sitter_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
valid_until TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(dog_id, sitter_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sitting_sub_sitter ON sitting_subscriptions(sitter_id, valid_until);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sitting_sub_owner ON sitting_subscriptions(owner_id);
|
||||||
|
""")
|
||||||
|
logger.info("Migration: sitting_subscriptions Tabelle bereit.")
|
||||||
|
|
||||||
# Walk-Einladungen (RSVP)
|
# Walk-Einladungen (RSVP)
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS walk_invitations (
|
CREATE TABLE IF NOT EXISTS walk_invitations (
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ from routes.widget import router as widget_router
|
||||||
from routes.notifications import router as notifications_router
|
from routes.notifications import router as notifications_router
|
||||||
from routes.services import router as services_router
|
from routes.services import router as services_router
|
||||||
from routes.ratings import router as ratings_router
|
from routes.ratings import router as ratings_router
|
||||||
|
from routes.sitting_access import router as sitting_access_router
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -127,6 +128,7 @@ app.include_router(widget_router, prefix="/api/widget", tags=["Widget"
|
||||||
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
|
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
|
||||||
app.include_router(services_router, prefix="/api/services", tags=["Services"])
|
app.include_router(services_router, prefix="/api/services", tags=["Services"])
|
||||||
app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"])
|
app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"])
|
||||||
|
app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=["SittingAccess"])
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,29 @@ def _own_dog(dog_id: int, user_id: int, conn):
|
||||||
return dog
|
return dog
|
||||||
|
|
||||||
|
|
||||||
|
def _can_read_dog(dog_id: int, user_id: int, conn):
|
||||||
|
"""Eigener Hund ODER geteilter Hund ODER aktiver Sitter-Zugang (Lesezugriff)."""
|
||||||
|
dog = conn.execute(
|
||||||
|
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
|
||||||
|
).fetchone()
|
||||||
|
if not dog:
|
||||||
|
dog = conn.execute(
|
||||||
|
"""SELECT d.id FROM dogs d
|
||||||
|
JOIN dog_shares ds ON ds.dog_id = d.id
|
||||||
|
WHERE d.id=? AND ds.shared_with_id=? AND ds.accepted_at IS NOT NULL""",
|
||||||
|
(dog_id, user_id)
|
||||||
|
).fetchone()
|
||||||
|
if not dog:
|
||||||
|
dog = conn.execute(
|
||||||
|
"""SELECT 1 FROM sitting_subscriptions
|
||||||
|
WHERE dog_id=? AND sitter_id=? AND valid_until >= date('now')""",
|
||||||
|
(dog_id, user_id)
|
||||||
|
).fetchone()
|
||||||
|
if not dog:
|
||||||
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
return dog
|
||||||
|
|
||||||
|
|
||||||
def _validate_dog_ids(dog_ids: list[int], primary: int, user_id: int, conn) -> list[int]:
|
def _validate_dog_ids(dog_ids: list[int], primary: int, user_id: int, conn) -> list[int]:
|
||||||
"""Stellt sicher dass alle IDs dem User gehören. Gibt die bereinigte Liste zurück."""
|
"""Stellt sicher dass alle IDs dem User gehören. Gibt die bereinigte Liste zurück."""
|
||||||
all_ids = list({primary} | set(dog_ids))
|
all_ids = list({primary} | set(dog_ids))
|
||||||
|
|
@ -123,7 +146,7 @@ async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
|
||||||
q: Optional[str] = None, milestone: int = 0,
|
q: Optional[str] = None, milestone: int = 0,
|
||||||
user=Depends(get_current_user)):
|
user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], conn)
|
_can_read_dog(dog_id, user["id"], conn)
|
||||||
extra = "AND (d.is_milestone=1 OR d.typ='meilenstein')" if milestone else ""
|
extra = "AND (d.is_milestone=1 OR d.typ='meilenstein')" if milestone else ""
|
||||||
if q:
|
if q:
|
||||||
pattern = f"%{q}%"
|
pattern = f"%{q}%"
|
||||||
|
|
@ -167,8 +190,24 @@ async def create_diary(dog_id: int, data: DiaryCreate,
|
||||||
pass
|
pass
|
||||||
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], conn)
|
# Erlaubnis: eigener Hund ODER aktiver Sitter-Zugang
|
||||||
all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn)
|
dog = conn.execute("SELECT user_id FROM dogs WHERE id=?", (dog_id,)).fetchone()
|
||||||
|
is_owner = dog and dog["user_id"] == user["id"]
|
||||||
|
is_sitter = conn.execute("""
|
||||||
|
SELECT 1 FROM sitting_subscriptions
|
||||||
|
WHERE dog_id=? AND sitter_id=? AND valid_until >= date('now')
|
||||||
|
""", (dog_id, user["id"])).fetchone()
|
||||||
|
if not is_owner and not is_sitter:
|
||||||
|
# Fallback: shared dog check
|
||||||
|
try:
|
||||||
|
_own_dog(dog_id, user["id"], conn)
|
||||||
|
except HTTPException:
|
||||||
|
raise HTTPException(403, "Kein Zugriff auf diesen Hund.")
|
||||||
|
# Sitter darf nur den Gasthund als einzigen Hund eintragen
|
||||||
|
if is_sitter and not is_owner:
|
||||||
|
all_dogs = [dog_id]
|
||||||
|
else:
|
||||||
|
all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn)
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO diary
|
"""INSERT INTO diary
|
||||||
|
|
@ -320,7 +359,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float,
|
||||||
@router.get("/{dog_id}/diary/{entry_id}")
|
@router.get("/{dog_id}/diary/{entry_id}")
|
||||||
async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], conn)
|
_can_read_dog(dog_id, user["id"], conn)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT DISTINCT d.* FROM diary d
|
"""SELECT DISTINCT d.* FROM diary d
|
||||||
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||||
|
|
@ -339,7 +378,17 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
||||||
async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
|
async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
|
||||||
user=Depends(get_current_user)):
|
user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], conn)
|
# Nur Besitzer des Hundes darf bearbeiten, NICHT Sitter
|
||||||
|
entry_owner = conn.execute(
|
||||||
|
"SELECT d.user_id FROM diary dg JOIN dogs d ON d.id=dg.dog_id WHERE dg.id=?",
|
||||||
|
(entry_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not entry_owner or entry_owner["user_id"] != user["id"]:
|
||||||
|
# Prüfen ob geteilter Hund (dog_shares)
|
||||||
|
try:
|
||||||
|
_own_dog(dog_id, user["id"], conn)
|
||||||
|
except HTTPException:
|
||||||
|
raise HTTPException(403, "Nur der Besitzer darf Einträge bearbeiten.")
|
||||||
|
|
||||||
# Prüfen ob Eintrag diesem Hund gehört (direkt oder via diary_dogs)
|
# Prüfen ob Eintrag diesem Hund gehört (direkt oder via diary_dogs)
|
||||||
exists = conn.execute(
|
exists = conn.execute(
|
||||||
|
|
@ -383,7 +432,16 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
|
||||||
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
|
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
|
||||||
async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
_own_dog(dog_id, user["id"], conn)
|
# Nur Besitzer des Hundes darf löschen, NICHT Sitter
|
||||||
|
entry_owner = conn.execute(
|
||||||
|
"SELECT d.user_id FROM diary dg JOIN dogs d ON d.id=dg.dog_id WHERE dg.id=?",
|
||||||
|
(entry_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not entry_owner or entry_owner["user_id"] != user["id"]:
|
||||||
|
try:
|
||||||
|
_own_dog(dog_id, user["id"], conn)
|
||||||
|
except HTTPException:
|
||||||
|
raise HTTPException(403, "Nur der Besitzer darf Einträge löschen.")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
|
"DELETE FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,30 @@ async def list_dogs(user=Depends(get_current_user)):
|
||||||
WHERE ds.shared_with_id = ? AND ds.accepted_at IS NOT NULL""",
|
WHERE ds.shared_with_id = ? AND ds.accepted_at IS NOT NULL""",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in own] + [dict(r) for r in shared]
|
guest_rows = conn.execute("""
|
||||||
|
SELECT d.*, ss.id AS sub_id, ss.valid_until AS sitting_until,
|
||||||
|
u.name AS owner_name, NULL AS shared_by, NULL AS share_role
|
||||||
|
FROM sitting_subscriptions ss
|
||||||
|
JOIN dogs d ON d.id = ss.dog_id
|
||||||
|
JOIN users u ON u.id = ss.owner_id
|
||||||
|
WHERE ss.sitter_id = ?
|
||||||
|
AND ss.valid_until >= date('now')
|
||||||
|
""", (user["id"],)).fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for r in own:
|
||||||
|
d = dict(r)
|
||||||
|
d["is_guest"] = False
|
||||||
|
result.append(d)
|
||||||
|
for r in shared:
|
||||||
|
d = dict(r)
|
||||||
|
d["is_guest"] = False
|
||||||
|
result.append(d)
|
||||||
|
for r in guest_rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["is_guest"] = True
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
|
|
|
||||||
72
backend/routes/sitting_access.py
Normal file
72
backend/routes/sitting_access.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from database import db
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class AccessCreate(BaseModel):
|
||||||
|
dog_id: int
|
||||||
|
sitter_id: int
|
||||||
|
valid_until: str # 'YYYY-MM-DD'
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def grant_access(data: AccessCreate, user=Depends(get_current_user)):
|
||||||
|
"""Besitzer gewährt Sitter-Zugang zu seinem Hund."""
|
||||||
|
with db() as conn:
|
||||||
|
dog = conn.execute("SELECT id, user_id FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
|
||||||
|
if not dog or dog["user_id"] != user["id"]:
|
||||||
|
raise HTTPException(403, "Nicht dein Hund.")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO sitting_subscriptions (dog_id, owner_id, sitter_id, valid_until)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(dog_id, sitter_id) DO UPDATE SET valid_until=excluded.valid_until
|
||||||
|
""", (data.dog_id, user["id"], data.sitter_id, data.valid_until))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sub_id}")
|
||||||
|
async def revoke_access(sub_id: int, user=Depends(get_current_user)):
|
||||||
|
"""Besitzer widerruft Zugang (oder Sitter meldet sich selbst ab)."""
|
||||||
|
with db() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM sitting_subscriptions WHERE id=?", (sub_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Nicht gefunden.")
|
||||||
|
if row["owner_id"] != user["id"] and row["sitter_id"] != user["id"]:
|
||||||
|
raise HTTPException(403, "Kein Zugriff.")
|
||||||
|
conn.execute("DELETE FROM sitting_subscriptions WHERE id=?", (sub_id,))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my")
|
||||||
|
async def my_subscriptions(user=Depends(get_current_user)):
|
||||||
|
"""Gibt alle aktiven Gasthunde zurück (als Sitter oder Besitzer)."""
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT ss.id, ss.dog_id, ss.valid_until,
|
||||||
|
d.name AS dog_name, d.foto_url, d.rasse,
|
||||||
|
d.foto_zoom, d.foto_offset_x, d.foto_offset_y,
|
||||||
|
u.name AS owner_name
|
||||||
|
FROM sitting_subscriptions ss
|
||||||
|
JOIN dogs d ON d.id = ss.dog_id
|
||||||
|
JOIN users u ON u.id = ss.owner_id
|
||||||
|
WHERE ss.sitter_id = ?
|
||||||
|
AND ss.valid_until >= date('now')
|
||||||
|
""", (user["id"],)).fetchall()
|
||||||
|
granted = conn.execute("""
|
||||||
|
SELECT ss.id, ss.dog_id, ss.valid_until,
|
||||||
|
d.name AS dog_name,
|
||||||
|
u.name AS sitter_name
|
||||||
|
FROM sitting_subscriptions ss
|
||||||
|
JOIN dogs d ON d.id = ss.dog_id
|
||||||
|
JOIN users u ON u.id = ss.sitter_id
|
||||||
|
WHERE ss.owner_id = ? AND ss.valid_until >= date('now')
|
||||||
|
""", (user["id"],)).fetchall()
|
||||||
|
return {
|
||||||
|
"as_sitter": [dict(r) for r in rows],
|
||||||
|
"as_owner": [dict(r) for r in granted],
|
||||||
|
}
|
||||||
|
|
@ -510,6 +510,15 @@ const API = (() => {
|
||||||
deactivate(id) { return del(`/services/${id}`); },
|
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 = {
|
const importData = {
|
||||||
notestation(dogId, file) {
|
notestation(dogId, file) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|
@ -542,7 +551,7 @@ const API = (() => {
|
||||||
get, post, put, patch, del, upload,
|
get, post, put, patch, del, upload,
|
||||||
auth, dogs, diary, health, tieraerzte, poison,
|
auth, dogs, diary, health, tieraerzte, poison,
|
||||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||||
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings,
|
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess,
|
||||||
subscribeToPush, getLocation,
|
subscribeToPush, getLocation,
|
||||||
APIError,
|
APIError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '213'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '214'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
@ -611,8 +611,13 @@ const App = (() => {
|
||||||
|
|
||||||
const titleClass = ctxId === 'sb' ? 'sidebar-logo-text' : 'header-title';
|
const titleClass = ctxId === 'sb' ? 'sidebar-logo-text' : 'header-title';
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="dog-sw-active" id="dog-sw-active-${ctxId}" title="${UI.escape(dog.name)} bearbeiten">
|
<div class="dog-sw-active" id="dog-sw-active-${ctxId}" title="${UI.escape(dog.name)} bearbeiten"
|
||||||
|
style="position:relative">
|
||||||
${avHtml(dog)}
|
${avHtml(dog)}
|
||||||
|
${dog.is_guest ? `<span style="position:absolute;bottom:-2px;right:-2px;
|
||||||
|
background:var(--c-primary);color:#fff;border-radius:var(--radius-full);
|
||||||
|
font-size:8px;font-weight:700;padding:1px 4px;line-height:1.4;
|
||||||
|
border:1px solid var(--c-surface)">GAST</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<span class="${titleClass} dog-sw-title" style="cursor:pointer" title="${UI.escape(dog.name)} bearbeiten">${UI.escape(dog.name)}</span>
|
<span class="${titleClass} dog-sw-title" style="cursor:pointer" title="${UI.escape(dog.name)} bearbeiten">${UI.escape(dog.name)}</span>
|
||||||
${othersHtml}`;
|
${othersHtml}`;
|
||||||
|
|
|
||||||
|
|
@ -452,7 +452,7 @@ window.Page_diary = (() => {
|
||||||
: ''}
|
: ''}
|
||||||
${dogsHtml}
|
${dogsHtml}
|
||||||
${photo}
|
${photo}
|
||||||
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-4)" id="detail-edit">Bearbeiten</button>
|
${!_appState?.activeDog?.is_guest ? `<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-4)" id="detail-edit">Bearbeiten</button>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
UI.modal.open({ title: entry.titel || typ.label, body });
|
UI.modal.open({ title: entry.titel || typ.label, body });
|
||||||
|
|
|
||||||
|
|
@ -177,31 +177,48 @@ window.Page_dog_profile = (() => {
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||||
<button class="btn btn-primary w-full" id="dp-edit-btn">
|
${!dog.is_guest ? `<button class="btn btn-primary w-full" id="dp-edit-btn">
|
||||||
Profil bearbeiten
|
Profil bearbeiten
|
||||||
</button>
|
</button>` : ''}
|
||||||
<div style="display:flex;gap:var(--space-2)">
|
<div style="display:flex;gap:var(--space-2)">
|
||||||
<button class="btn btn-secondary" style="flex:1" id="dp-ausweis-btn">
|
<button class="btn btn-secondary" style="flex:1" id="dp-ausweis-btn">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
|
||||||
Ausweis
|
Ausweis
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" style="flex:1" id="dp-share-btn">
|
${!dog.is_guest ? `<button class="btn btn-secondary" style="flex:1" id="dp-share-btn">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
|
||||||
Teilen
|
Teilen
|
||||||
</button>
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
|
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
|
||||||
+ Weiteren Hund anlegen
|
+ Weiteren Hund anlegen
|
||||||
</button>
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${dog.user_id === _appState.user?.id ? `
|
||||||
|
<div class="card" style="margin-bottom:var(--space-5)">
|
||||||
|
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
|
||||||
|
<div style="font-weight:600">Sitter-Zugang</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||||
|
Gib einem Freund temporären Schreibzugang für diesen Hund
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade…</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Foto-Editor öffnen
|
// Foto-Editor öffnen
|
||||||
document.getElementById('dp-photo-edit-btn')?.addEventListener('click', () => {
|
document.getElementById('dp-photo-edit-btn')?.addEventListener('click', () => {
|
||||||
_showPhotoEditor(dog);
|
_showPhotoEditor(dog);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sitter-Zugang laden (nur für Besitzer)
|
||||||
|
if (dog.user_id === _appState.user?.id) {
|
||||||
|
_loadSittingAccess(dog.id);
|
||||||
|
}
|
||||||
// NFC-Link kopieren
|
// NFC-Link kopieren
|
||||||
document.getElementById('dp-copy-link-btn')?.addEventListener('click', async () => {
|
document.getElementById('dp-copy-link-btn')?.addEventListener('click', async () => {
|
||||||
const url = `https://banyaro.app/hund/${dog.id}`;
|
const url = `https://banyaro.app/hund/${dog.id}`;
|
||||||
|
|
@ -238,6 +255,113 @@ window.Page_dog_profile = (() => {
|
||||||
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// SITTER-ZUGANG
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _loadSittingAccess(dogId) {
|
||||||
|
const wrap = document.getElementById('dp-sitting-access');
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [accessData, friendsData] = await Promise.all([
|
||||||
|
API.sittingAccess.my(),
|
||||||
|
API.friends.list(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const active = (accessData.as_owner || []).filter(s => s.dog_id === dogId);
|
||||||
|
const friends = (friendsData?.friends || []);
|
||||||
|
|
||||||
|
let activeHtml = '';
|
||||||
|
if (active.length) {
|
||||||
|
activeHtml = active.map(s => `
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-2);
|
||||||
|
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||||||
|
border-radius:var(--radius-md);margin-bottom:var(--space-2)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
|
||||||
|
<div style="flex:1;font-size:var(--text-sm)">
|
||||||
|
<strong>${_esc(s.sitter_name)}</strong>
|
||||||
|
<span style="color:var(--c-text-muted)"> · bis ${_esc(s.valid_until)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-link btn-sm sa-revoke-btn" data-sub-id="${s.id}"
|
||||||
|
style="color:var(--c-danger);padding:0">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendOptions = friends.length
|
||||||
|
? friends.map(f => `<option value="${f.friend_id}">${_esc(f.friend_name)}</option>`).join('')
|
||||||
|
: '<option value="" disabled>Keine Freunde vorhanden</option>';
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const defaultUntil = new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
${activeHtml}
|
||||||
|
${friends.length ? `
|
||||||
|
<div style="margin-top:var(--space-3)">
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
|
||||||
|
margin-bottom:var(--space-2);font-weight:600">Zugang gewähren</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr auto;gap:var(--space-2);
|
||||||
|
align-items:end">
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Freund</label>
|
||||||
|
<select class="form-control form-control-sm" id="sa-friend-select">
|
||||||
|
<option value="">Freund wählen…</option>
|
||||||
|
${friendOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Gültig bis</label>
|
||||||
|
<input class="form-control form-control-sm" type="date" id="sa-until-input"
|
||||||
|
value="${defaultUntil}" min="${today}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm w-full" id="sa-grant-btn"
|
||||||
|
style="margin-top:var(--space-2)">
|
||||||
|
Zugang gewähren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">
|
||||||
|
Füge zuerst Freunde hinzu, um ihnen Zugang zu gewähren.
|
||||||
|
</p>`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Event-Listener
|
||||||
|
wrap.querySelectorAll('.sa-revoke-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const subId = parseInt(btn.dataset.subId);
|
||||||
|
try {
|
||||||
|
await API.sittingAccess.revoke(subId);
|
||||||
|
_loadSittingAccess(dogId);
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast.error(e.message || 'Fehler beim Widerrufen.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sa-grant-btn')?.addEventListener('click', async () => {
|
||||||
|
const sitterId = parseInt(document.getElementById('sa-friend-select').value);
|
||||||
|
const validUntil = document.getElementById('sa-until-input').value;
|
||||||
|
if (!sitterId) { UI.toast.warning('Bitte einen Freund auswählen.'); return; }
|
||||||
|
if (!validUntil) { UI.toast.warning('Bitte ein Datum angeben.'); return; }
|
||||||
|
const btn = document.getElementById('sa-grant-btn');
|
||||||
|
UI.setLoading(btn, true);
|
||||||
|
try {
|
||||||
|
await API.sittingAccess.grant({ dog_id: dogId, sitter_id: sitterId, valid_until: validUntil });
|
||||||
|
UI.toast.success('Sitter-Zugang gewährt.');
|
||||||
|
_loadSittingAccess(dogId);
|
||||||
|
} catch (e) {
|
||||||
|
UI.setLoading(btn, false);
|
||||||
|
UI.toast.error(e.message || 'Fehler beim Gewähren.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (wrap) wrap.innerHTML = '<p style="color:var(--c-danger);font-size:var(--text-sm)">Fehler beim Laden.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _showChipEdit(dog) {
|
function _showChipEdit(dog) {
|
||||||
UI.modal.open({
|
UI.modal.open({
|
||||||
title: 'Transpondernummer',
|
title: 'Transpondernummer',
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v236';
|
const CACHE_VERSION = 'by-v237';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue