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