Sprint 16: Chat-Fotos/Online/Read-Receipts, Gesundheit-Dokumente löschen, Bugfixes

- Chat: Foto-Versand (POST /api/chat/conversations/{id}/upload, media_url/media_type)
- Chat: Online-Indikator (last_seen Heartbeat, grüner Dot, 3min-Fenster)
- Chat: Read Receipts (read_at, Einzel-/Doppelhaken-Icons)
- Gesundheit: Dokument löschen (DELETE .../dokument, Datei + DB-Eintrag)
- Bug: events.user_id NOT NULL → nullable (Table-Recreation-Migration)
- Bug: scheduler INSERT user_id 0 → NULL
- Bug: Wikidata Rate-Limit: sleep 0.3s→1.0s, retries 2→4, exponentielles Backoff
- SW: by-v146, APP_VER 119
This commit is contained in:
rene 2026-04-17 22:38:33 +02:00
parent 34f29f9d0a
commit a7753c9cf5
15 changed files with 375 additions and 43 deletions

View file

@ -1,6 +1,9 @@
"""BAN YARO — Direktnachrichten"""
import logging
from fastapi import APIRouter, Depends, HTTPException
import os
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from database import db
from auth import get_current_user
@ -8,6 +11,12 @@ from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
CHAT_DIR = os.path.join(MEDIA_DIR, "chat")
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
ONLINE_MINUTES = 3
def _conv_key(a: int, b: int):
"""Normalisiert Konversations-User-IDs: user_a < user_b."""
@ -17,11 +26,13 @@ def _conv_key(a: int, b: int):
@router.get("/conversations")
async def list_conversations(user=Depends(get_current_user)):
uid = user["id"]
online_cutoff = (datetime.utcnow() - timedelta(minutes=ONLINE_MINUTES)).isoformat()
with db() as conn:
rows = conn.execute("""
SELECT c.id, c.last_msg_at,
CASE WHEN c.user_a_id=? THEN c.user_b_id ELSE c.user_a_id END AS partner_id,
CASE WHEN c.user_a_id=? THEN ub.name ELSE ua.name END AS partner_name,
CASE WHEN c.user_a_id=? THEN ub.last_seen ELSE ua.last_seen END AS partner_last_seen,
(SELECT text FROM direct_messages
WHERE conversation_id=c.id AND is_deleted=0
ORDER BY created_at DESC LIMIT 1) AS last_text,
@ -39,8 +50,14 @@ async def list_conversations(user=Depends(get_current_user)):
JOIN users ub ON ub.id=c.user_b_id
WHERE c.user_a_id=? OR c.user_b_id=?
ORDER BY COALESCE(c.last_msg_at, c.created_at) DESC
""", (uid, uid, uid, uid, uid, uid)).fetchall()
return [dict(r) for r in rows]
""", (uid, uid, uid, uid, uid, uid, uid)).fetchall()
result = []
for r in rows:
d = dict(r)
last_seen = d.get("partner_last_seen")
d["partner_online"] = bool(last_seen and last_seen >= online_cutoff)
result.append(d)
return result
class StartConvModel(BaseModel):
@ -79,6 +96,8 @@ async def start_conversation(data: StartConvModel, user=Depends(get_current_user
async def get_messages(conv_id: int, offset: int = 0, limit: int = 50,
user=Depends(get_current_user)):
uid = user["id"]
online_cutoff = (datetime.utcnow() - timedelta(minutes=ONLINE_MINUTES)).isoformat()
now_iso = datetime.utcnow().isoformat()
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
@ -88,10 +107,20 @@ async def get_messages(conv_id: int, offset: int = 0, limit: int = 50,
raise HTTPException(404, "Konversation nicht gefunden.")
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
partner = conn.execute("SELECT name FROM users WHERE id=?", (partner_id,)).fetchone()
partner = conn.execute(
"SELECT name, last_seen FROM users WHERE id=?", (partner_id,)
).fetchone()
# Ungelesene Nachrichten des Partners als gelesen markieren
conn.execute("""
UPDATE direct_messages
SET read_at=?
WHERE conversation_id=? AND sender_id!=? AND read_at IS NULL
""", (now_iso, conv_id, uid))
msgs = conn.execute("""
SELECT m.id, m.sender_id, m.text, m.is_deleted, m.created_at,
m.media_url, m.media_type, m.read_at,
u.name AS sender_name
FROM direct_messages m
JOIN users u ON u.id=m.sender_id
@ -100,10 +129,14 @@ async def get_messages(conv_id: int, offset: int = 0, limit: int = 50,
LIMIT ? OFFSET ?
""", (conv_id, limit, offset)).fetchall()
partner_last_seen = partner["last_seen"] if partner else None
partner_online = bool(partner_last_seen and partner_last_seen >= online_cutoff)
return {
"conversation_id": conv_id,
"partner_id": partner_id,
"partner_name": partner["name"] if partner else "Unbekannt",
"partner_online": partner_online,
"messages": [dict(m) for m in msgs],
}
@ -157,6 +190,71 @@ async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_curren
return {"id": msg_id, "ok": True}
@router.post("/conversations/{conv_id}/upload", status_code=201)
async def upload_photo(conv_id: int, file: UploadFile = File(...),
user=Depends(get_current_user)):
uid = user["id"]
if file.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(400, "Nur JPEG, PNG, GIF und WebP erlaubt.")
data = await file.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(400, "Datei zu groß (max. 10 MB).")
os.makedirs(CHAT_DIR, exist_ok=True)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(CHAT_DIR, filename)
with open(path, "wb") as f:
f.write(data)
media_url = f"/media/chat/{filename}"
media_type = file.content_type
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404, "Konversation nicht gefunden.")
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
cur = conn.execute("""
INSERT INTO direct_messages (conversation_id, sender_id, text, media_url, media_type)
VALUES (?,?,?,?,?)
""", (conv_id, uid, "", media_url, media_type))
msg_id = cur.lastrowid
conn.execute(
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
(conv_id,)
)
try:
from routes.push import send_push_to_user
send_push_to_user(partner_id, {
"title": f"Foto von {user['name']}",
"body": "hat dir ein Foto geschickt",
"type": "chat_message",
"tag": f"chat-{conv_id}",
"data": {"page": "chat", "conversation_id": conv_id},
})
except Exception:
pass
return {"id": msg_id, "media_url": media_url, "media_type": media_type, "ok": True}
@router.post("/heartbeat")
async def heartbeat(user=Depends(get_current_user)):
uid = user["id"]
now_iso = datetime.utcnow().isoformat()
with db() as conn:
conn.execute("UPDATE users SET last_seen=? WHERE id=?", (now_iso, uid))
return {"ok": True}
@router.post("/conversations/{conv_id}/read")
async def mark_read(conv_id: int, user=Depends(get_current_user)):
uid = user["id"]