feat: prevent duplicate rest day types per date (Migration 012)
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

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:
Lars 2026-03-22 17:36:49 +01:00
parent f2e2aff17f
commit f87b93ce2f
2 changed files with 67 additions and 13 deletions

View 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.';

View File

@ -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")