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 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,22 +90,38 @@ def create_rest_day(
|
|||
|
||||
# Convert RestConfig to dict for JSONB storage
|
||||
config_dict = data.rest_config.model_dump()
|
||||
focus = data.rest_config.focus
|
||||
|
||||
try:
|
||||
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(
|
||||
"""
|
||||
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
|
||||
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, 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."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{rest_day_id}")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user