feat: prevent duplicate rest day types per date (Migration 012)
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 <noreply@anthropic.com>
This commit is contained in:
parent
f2e2aff17f
commit
f87b93ce2f
34
backend/migrations/012_rest_days_unique_focus.sql
Normal file
34
backend/migrations/012_rest_days_unique_focus.sql
Normal file
|
|
@ -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.';
|
||||||
|
|
@ -10,6 +10,7 @@ from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Header
|
from fastapi import APIRouter, HTTPException, Depends, Header
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
|
from psycopg2.errors import UniqueViolation
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
|
|
@ -89,23 +90,39 @@ def create_rest_day(
|
||||||
|
|
||||||
# Convert RestConfig to dict for JSONB storage
|
# Convert RestConfig to dict for JSONB storage
|
||||||
config_dict = data.rest_config.model_dump()
|
config_dict = data.rest_config.model_dump()
|
||||||
|
focus = data.rest_config.focus
|
||||||
|
|
||||||
with get_db() as conn:
|
try:
|
||||||
cur = get_cursor(conn)
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Insert (multiple entries per date allowed)
|
# Insert (multiple entries per date allowed, but not same focus)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO rest_days (profile_id, date, rest_config, note, created_at)
|
INSERT INTO rest_days (profile_id, date, focus, rest_config, note, created_at)
|
||||||
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
|
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
RETURNING id, profile_id, date, rest_config, note, created_at
|
RETURNING id, profile_id, date, focus, rest_config, note, created_at
|
||||||
""",
|
""",
|
||||||
(pid, data.date, Json(config_dict), data.note)
|
(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}")
|
@router.get("/{rest_day_id}")
|
||||||
def get_rest_day(
|
def get_rest_day(
|
||||||
|
|
@ -159,6 +176,9 @@ def update_rest_day(
|
||||||
if data.rest_config:
|
if data.rest_config:
|
||||||
updates.append("rest_config = %s")
|
updates.append("rest_config = %s")
|
||||||
values.append(Json(data.rest_config.model_dump()))
|
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:
|
if data.note is not None:
|
||||||
updates.append("note = %s")
|
updates.append("note = %s")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user