From f87b93ce2f6bc0374fed99d7c6ed69531a269f8d Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 17:36:49 +0100 Subject: [PATCH] feat: prevent duplicate rest day types per date (Migration 012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: User can create multiple rest days of same type per date (e.g., 2x Mental Rest on 2026-03-23) - makes no sense. Solution: UNIQUE constraint on (profile_id, date, focus) ## Migration 012: - Add focus column (extracted from rest_config JSONB) - Populate from existing data - Add NOT NULL constraint - Add CHECK constraint (valid focus values) - Add UNIQUE constraint (profile_id, date, focus) - Add index for performance ## Backend: - Insert focus column alongside rest_config - Handle UniqueViolation gracefully - User-friendly error: "Du hast bereits einen Ruhetag 'Muskelregeneration' für 23.03." ## Benefits: - DB-level enforcement (clean) - Fast queries (no JSONB scan) - Clear error messages - Prevents: 2x muscle_recovery same day - Allows: muscle_recovery + mental_rest same day ✓ Co-Authored-By: Claude Opus 4.6 --- .../migrations/012_rest_days_unique_focus.sql | 34 ++++++++++++++ backend/routers/rest_days.py | 46 +++++++++++++------ 2 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/012_rest_days_unique_focus.sql diff --git a/backend/migrations/012_rest_days_unique_focus.sql b/backend/migrations/012_rest_days_unique_focus.sql new file mode 100644 index 0000000..f7f19e2 --- /dev/null +++ b/backend/migrations/012_rest_days_unique_focus.sql @@ -0,0 +1,34 @@ +-- Migration 012: Unique constraint on (profile_id, date, focus) +-- v9d Phase 2a: Prevent duplicate rest day types per date +-- Date: 2026-03-22 + +-- Add focus column (extracted from rest_config for performance + constraints) +ALTER TABLE rest_days +ADD COLUMN IF NOT EXISTS focus VARCHAR(20); + +-- Populate from existing JSONB data +UPDATE rest_days +SET focus = rest_config->>'focus' +WHERE focus IS NULL; + +-- Make NOT NULL (safe because we just populated all rows) +ALTER TABLE rest_days +ALTER COLUMN focus SET NOT NULL; + +-- Add CHECK constraint for valid focus values +ALTER TABLE rest_days +ADD CONSTRAINT valid_focus CHECK ( + focus IN ('muscle_recovery', 'cardio_recovery', 'mental_rest', 'deload', 'injury') +); + +-- Add UNIQUE constraint: Same profile + date + focus = duplicate +ALTER TABLE rest_days +ADD CONSTRAINT unique_rest_day_per_focus +UNIQUE (profile_id, date, focus); + +-- Add index for efficient queries by focus +CREATE INDEX IF NOT EXISTS idx_rest_days_focus +ON rest_days(focus); + +-- Comment for documentation +COMMENT ON COLUMN rest_days.focus IS 'Extracted from rest_config.focus for performance and constraints. Prevents duplicate rest day types per date.'; diff --git a/backend/routers/rest_days.py b/backend/routers/rest_days.py index 490de6a..6d2fde4 100644 --- a/backend/routers/rest_days.py +++ b/backend/routers/rest_days.py @@ -10,6 +10,7 @@ from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException, Depends, Header from pydantic import BaseModel, Field from psycopg2.extras import Json +from psycopg2.errors import UniqueViolation from db import get_db, get_cursor, r2d from auth import require_auth @@ -89,23 +90,39 @@ def create_rest_day( # Convert RestConfig to dict for JSONB storage config_dict = data.rest_config.model_dump() + focus = data.rest_config.focus - with get_db() as conn: - cur = get_cursor(conn) + try: + with get_db() as conn: + cur = get_cursor(conn) - # Insert (multiple entries per date allowed) - cur.execute( - """ - INSERT INTO rest_days (profile_id, date, rest_config, note, created_at) - VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP) - RETURNING id, profile_id, date, rest_config, note, created_at - """, - (pid, data.date, Json(config_dict), data.note) + # Insert (multiple entries per date allowed, but not same focus) + cur.execute( + """ + INSERT INTO rest_days (profile_id, date, focus, rest_config, note, created_at) + VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP) + RETURNING id, profile_id, date, focus, rest_config, note, created_at + """, + (pid, data.date, focus, Json(config_dict), data.note) + ) + + result = cur.fetchone() + return r2d(result) + except UniqueViolation: + # User-friendly error for duplicate focus + focus_labels = { + 'muscle_recovery': 'Muskelregeneration', + 'cardio_recovery': 'Cardio-Erholung', + 'mental_rest': 'Mentale Erholung', + 'deload': 'Deload', + 'injury': 'Verletzungspause', + } + focus_label = focus_labels.get(focus, focus) + raise HTTPException( + 400, + f"Du hast bereits einen Ruhetag '{focus_label}' für {data.date}. Bitte wähle einen anderen Typ oder lösche den bestehenden Eintrag." ) - result = cur.fetchone() - return r2d(result) - @router.get("/{rest_day_id}") def get_rest_day( @@ -159,6 +176,9 @@ def update_rest_day( if data.rest_config: updates.append("rest_config = %s") values.append(Json(data.rest_config.model_dump())) + # Also update focus column if config changed + updates.append("focus = %s") + values.append(data.rest_config.focus) if data.note is not None: updates.append("note = %s")