From 833ad04a6fd643245edb6d366e39f173e03242ca Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Mon, 23 Mar 2026 20:50:48 +0100 Subject: [PATCH] Add delete session functionality - DELETE /api/v1/sessions/{id}: only closed sessions, user-scoped - Returns 404 if not found/wrong user, 409 if session still open - Deletes session_logs first, then session (no CASCADE) - Frontend: trash button per session in SessionList (closed sessions only) - Confirm dialog before delete, toast feedback, list reloads after Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 3 +- backend/internal/handler/handler.go | 1 + backend/internal/handler/session.go | 32 ++++++ backend/internal/store/session_store.go | 31 ++++++ frontend/src/api/client.ts | 6 ++ .../src/components/history/SessionList.tsx | 101 +++++++++++++----- 6 files changed, 147 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6b69998..e5beb64 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ Store-Methoden geben nach Mutationen immer **frisch aus der DB gelesene Objekte* - Nicht gefunden (`sql.ErrNoRows`) → 404 - UNIQUE-Verletzung → 409 -Sentinel-Strings im Error-Message für Handler-Differenzierung: `"UNIQUE_VIOLATION:"`, `"SESSION_CLOSED"`. Diese werden mit `strings.Contains()` geprüft — kein custom error type. +Sentinel-Strings im Error-Message für Handler-Differenzierung: `"UNIQUE_VIOLATION:"`, `"SESSION_CLOSED"`, `"SESSION_OPEN"`, `"SESSION_NOT_FOUND"`. Diese werden mit `strings.Contains()` geprüft — kein custom error type. ### Routing @@ -74,6 +74,7 @@ Alle HTTP-Aufrufe gehen über `src/api/client.ts`. `ApiError` (extends Error) ha - `exercise_name` in `session_logs` **denormalisiert** gespeichert (damit gelöschte Übungen historische Daten nicht verwaisen lassen) - UNIQUE-Constraint auf `(session_id, exercise_id, set_number)` - Soft-Delete bei Übungen via `deleted_at` Timestamp +- Hard-Delete bei Sessions: löscht zuerst explizit `session_logs`, dann `sessions` (kein ON DELETE CASCADE) ## Konventionen diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 6ff0428..5e4579e 100755 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -42,6 +42,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/sessions", h.handleListSessions) mux.HandleFunc("GET /api/v1/sessions/{id}", h.handleGetSession) mux.HandleFunc("PUT /api/v1/sessions/{id}/end", h.handleEndSession) + mux.HandleFunc("DELETE /api/v1/sessions/{id}", h.handleDeleteSession) // Session Logs mux.HandleFunc("POST /api/v1/sessions/{id}/logs", h.handleCreateLog) diff --git a/backend/internal/handler/session.go b/backend/internal/handler/session.go index 9007f67..750565e 100755 --- a/backend/internal/handler/session.go +++ b/backend/internal/handler/session.go @@ -101,6 +101,38 @@ func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, session) } +// handleDeleteSession handles DELETE /api/v1/sessions/{id}. +// Löscht eine abgeschlossene Session samt aller Logs. Offene Sessions werden +// mit 409 abgelehnt. Sessions anderer Nutzer oder nicht vorhandene Sessions +// antworten mit 404. +func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + id, err := pathID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "Ungültige ID") + return + } + + err = h.store.DeleteSession(id, uid) + if err != nil { + if strings.Contains(err.Error(), "SESSION_NOT_FOUND") { + writeError(w, http.StatusNotFound, "Session nicht gefunden") + return + } + if strings.Contains(err.Error(), "SESSION_OPEN") { + writeError(w, http.StatusConflict, "Nur abgeschlossene Sessions können gelöscht werden") + return + } + writeError(w, http.StatusInternalServerError, "Fehler beim Löschen der Session") + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) { sessionID, err := pathID(r, "id") if err != nil { diff --git a/backend/internal/store/session_store.go b/backend/internal/store/session_store.go index 02e1878..bcc1a7c 100755 --- a/backend/internal/store/session_store.go +++ b/backend/internal/store/session_store.go @@ -207,6 +207,37 @@ func (s *Store) GetLastLog(exerciseID, userID int64) (*model.LastLogResponse, er return &resp, nil } +// DeleteSession löscht eine abgeschlossene Session und alle zugehörigen Logs. +// Gibt einen Fehler mit "SESSION_NOT_FOUND" zurück wenn die Session nicht existiert +// oder nicht zum angegebenen Nutzer gehört. Gibt einen Fehler mit "SESSION_OPEN" +// zurück wenn die Session noch nicht beendet wurde. +func (s *Store) DeleteSession(id, userID int64) error { + var endedAt *string + err := s.db.QueryRow( + `SELECT ended_at FROM sessions WHERE id = ? AND user_id = ?`, id, userID, + ).Scan(&endedAt) + if err == sql.ErrNoRows { + return fmt.Errorf("SESSION_NOT_FOUND: Session %d nicht gefunden", id) + } + if err != nil { + return fmt.Errorf("Session prüfen: %w", err) + } + if endedAt == nil { + return fmt.Errorf("SESSION_OPEN: Session ist noch nicht beendet") + } + + // Logs zuerst löschen (kein ON DELETE CASCADE garantiert), dann Session + _, err = s.db.Exec(`DELETE FROM session_logs WHERE session_id = ?`, id) + if err != nil { + return fmt.Errorf("Session-Logs löschen: %w", err) + } + _, err = s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("Session löschen: %w", err) + } + return nil +} + // checkSessionOpen prüft ob eine Session offen ist. func (s *Store) checkSessionOpen(sessionID int64) error { var endedAt *string diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f9bb89c..97cdb78 100755 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -189,6 +189,12 @@ export const api = { method: 'DELETE', }); }, + + delete(id: number): Promise { + return request(`/api/v1/sessions/${id}`, { + method: 'DELETE', + }); + }, }, stats: { diff --git a/frontend/src/components/history/SessionList.tsx b/frontend/src/components/history/SessionList.tsx index 8c10aea..98d91fa 100755 --- a/frontend/src/components/history/SessionList.tsx +++ b/frontend/src/components/history/SessionList.tsx @@ -2,12 +2,14 @@ import { useEffect, useState } from 'react'; import { useHistoryStore } from '../../stores/historyStore'; import { SessionDetail } from './SessionDetail'; import type { Session } from '../../types'; -import { api } from '../../api/client'; +import { api, ApiError } from '../../api/client'; +import { useToastStore } from '../../stores/toastStore'; export function SessionList() { const { sessions, loading, fetchSessions } = useHistoryStore(); const [expandedId, setExpandedId] = useState(null); const [expandedSession, setExpandedSession] = useState(null); + const [deletingId, setDeletingId] = useState(null); useEffect(() => { fetchSessions(50); @@ -38,6 +40,31 @@ export function SessionList() { }); }; + const handleDelete = async (e: React.MouseEvent, id: number) => { + e.stopPropagation(); + if (!confirm('Session wirklich löschen? Alle Sätze werden unwiderruflich entfernt.')) return; + + setDeletingId(id); + try { + await api.sessions.delete(id); + useToastStore.getState().addToast('success', 'Session erfolgreich gelöscht'); + // Aufgeklappte Session schließen falls sie gerade gelöscht wurde + if (expandedId === id) { + setExpandedId(null); + setExpandedSession(null); + } + fetchSessions(50); + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : 'Session konnte nicht gelöscht werden'; + useToastStore.getState().addToast('error', message); + } finally { + setDeletingId(null); + } + }; + const formatDuration = (start: string, end?: string) => { if (!end) return 'laufend'; const ms = new Date(end).getTime() - new Date(start).getTime(); @@ -68,33 +95,55 @@ export function SessionList() { key={session.id} className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden" > - + + {session.ended_at && ( + + )} + {expandedId === session.id && expandedSession && (