diff --git a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md
index 1e3cd54..7094d12 100644
--- a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md
+++ b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md
@@ -197,7 +197,7 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
-**Aktuell:** Phase B fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **Phase D** (Composite-MVP).
+**Aktuell:** Phase B abgeschlossen (Consumer-Audit 2026-04-16). **Phase C** Schreibpfad entschlackt (Sync abgestellt, Orchestrator als SSoT; Review 2026-04-16 + Regression `test_activity_insert_sql.py`). Nächster Schritt: **Phase D** (Composite-MVP).
---
diff --git a/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md b/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md
index e47174c..eef19d0 100644
--- a/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md
+++ b/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md
@@ -46,10 +46,12 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
---
-## 4. Bekannte Einschränkungen (Follow-up in Gitea)
+## 4. Validierung & Dry-Run (Stand 2026-07-23, Gitea #71)
-- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import.
-- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** – Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71).
+- Admin **„Format prüfen“** sendet dieselbe `import_row_processing`-Spec wie Speichern (`AdminCsvTemplateEditorPage` → `POST /api/admin/csv-templates/validate`).
+- **Profil-Mappings:** `POST /api/csv/mappings/{id}/copy` und `POST /api/csv/import` prüfen vor dem Schreiben mit **`validate_csv_template`** (HTTP 422 bei Fehlern).
+- **Diagnose:** `GET /api/csv/mappings/{id}/validate` — Strukturprüfung ohne Import.
+- **Nutzer-UI:** `UniversalCsvImportPage` zeigt `error_details` mit Zeile, `code` und `hint` (`CsvImportErrorDetails`).
---
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-25.md b/.claude/docs/working/gitea-close-2026-07-23/comment-25.md
new file mode 100644
index 0000000..d0e627b
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-25.md
@@ -0,0 +1,10 @@
+**Automatischer Audit (Code + Playwright, 2026-07-23)**
+
+Ziele-System ist im Repo umgesetzt:
+- Backend: `backend/routers/goals.py`, Focus Areas, Goal Types, Progress
+- Frontend: `frontend/src/pages/GoalsPage.jsx`, Nav `/goals` in `config/appNav.js`
+- Spec: `docs/issues/issue-50-phase-0a-goal-system.md`
+
+Playwright auf dev.mitai.jinkendo.de: `/goals` rendert ohne Fehler.
+
+Verbleibende KI-Goal-Erweiterungen ggf. als neues Issue.
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-35.md b/.claude/docs/working/gitea-close-2026-07-23/comment-35.md
new file mode 100644
index 0000000..369f363
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-35.md
@@ -0,0 +1,9 @@
+**Automatischer Audit (Code, 2026-07-23)**
+
+Deprecated Tabelle `subscriptions` ist aus dem aktiven Schema entfernt:
+- Kein Treffer in `backend/schema.sql`, `backend/migrations/`, Backend-Python
+- Membership nutzt `access_grants`, `tier_limits`, etc. (Migration v9c)
+
+Doku-Hinweis deprecated: `.claude/docs/technical/DATABASE.md`
+
+Optional Prod-Check: `\dt subscriptions` — sollte leer/nicht vorhanden sein.
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-36.md b/.claude/docs/working/gitea-close-2026-07-23/comment-36.md
new file mode 100644
index 0000000..924d292
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-36.md
@@ -0,0 +1,8 @@
+**Automatischer Audit (Code, 2026-07-23)**
+
+Bug behoben: JSONB-Insert für `abilities`/`profile` nutzt `psycopg2.extras.Json()`.
+
+- Fix-Commit: `2977050` — wrap abilities dict with Json() for JSONB insert
+- Aktuell: `backend/routers/admin_training_types.py` (`create_training_type`, Zeilen 111–112)
+
+Admin-UI: `frontend/src/pages/AdminTrainingTypesPage.jsx`
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-37.md b/.claude/docs/working/gitea-close-2026-07-23/comment-37.md
new file mode 100644
index 0000000..bd18a95
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-37.md
@@ -0,0 +1,23 @@
+**Umsetzung 2026-07-23 (nach Architektur-Abgleich)**
+
+## Architektur-Check vor Implementierung
+
+- **Phase C (Schreibpfad):** laut `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` bereits erledigt (Sync abgestellt, Orchestrator als SSoT, keine Aufrufer von `sync_column_backed_session_metrics`). Keine Doppel-Implementierung.
+- **Feature-ID:** Issue-Vorgabe `activity_entries` (nicht separates `activity_import`) — konsistent mit `create_activity` und Universal-CSV-Modul-Check in `csv_import.py`.
+- **Legacy-Endpoint bleibt:** Frontend nutzt weiterhin `POST /api/activity/import-csv` (`ActivityPage` Apple-Health-Panel); Universal-Pfad ist parallel (ARCH §8.2).
+
+## Änderungen
+
+**Backend** (`routers/activity.py`):
+- `check_feature_access(pid, "activity_entries")` vor Legacy-Import
+- HTTP 403 bei Limit (wie `nutrition/import-csv`)
+- `increment_feature_usage` pro neu eingefügter Zeile (`inserted`)
+
+**Frontend** (`ActivityPage.jsx` ImportPanel):
+- `UsageBadge` am Import-Titel
+- Drop-Zone deaktiviert bei Limit
+- Usage-Reload nach Import
+
+**Tests:** `tests/test_activity_import_feature_enforcement.py` (403 + Increment)
+
+Regression INSERT-SQL: `tests/test_activity_insert_sql.py` (grün)
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-38.md b/.claude/docs/working/gitea-close-2026-07-23/comment-38.md
new file mode 100644
index 0000000..b73506c
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-38.md
@@ -0,0 +1,22 @@
+**Umsetzung 2026-07-23 (UI-Teil; Backend war bereits erledigt)**
+
+## Ist-Zustand vorher
+
+- Backend: `POST /api/nutrition/import-csv` mit `check_feature_access('nutrition_entries')` und HTTP 403 — **bereits implementiert**
+- Frontend: FDDB-Import ohne Usage-Anzeige, Drop-Zone blieb bei Limit klickbar
+
+## Änderungen
+
+**Frontend** (`NutritionPage.jsx`, ImportPanel):
+- `getFeatureUsage()` → `nutrition_entries`
+- `UsageBadge` am Import-Titel
+- Drop-Zone und Paste-Import deaktiviert bei Limit
+- Usage-Reload nach erfolgreichem Import
+
+Analog zu Activity #37 (geschlossen 2026-07-23).
+
+**Playwright:** `tests/issue-audit.spec.js` — Badge am FDDB-Panel
+
+## Hinweis
+
+Universal-CSV (`/api/csv-import/import`) prüft zusätzlich `data_import` — Legacy FDDB-Pfad folgt dem Nutrition-Muster (nur `nutrition_entries`), konsistent mit Architektur §8.2.
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-40.md b/.claude/docs/working/gitea-close-2026-07-23/comment-40.md
new file mode 100644
index 0000000..2c5a76a
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-40.md
@@ -0,0 +1,7 @@
+**Automatischer Audit (Code + Playwright, 2026-07-23)**
+
+Logout-Button im Mobile-Header neben Avatar implementiert:
+- `frontend/src/App.jsx` — Button mit `title="Abmelden"`, Icon `LogOut`
+- Zusätzlich Desktop: `frontend/src/components/DesktopSidebar.jsx`
+
+Playwright auf dev.mitai.jinkendo.de: Button sichtbar und klickbar.
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-43.md b/.claude/docs/working/gitea-close-2026-07-23/comment-43.md
new file mode 100644
index 0000000..fa1a03c
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-43.md
@@ -0,0 +1,5 @@
+**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
+
+Inhalt identisch mit **#42** (Enhanced Debug/Prompt Analysis UI).
+
+Teilumsetzung existiert bereits (`Analysis.jsx` Experten-Modus, `WorkflowDebugPanel`). Weiterverfolgung unter #42.
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-54.md b/.claude/docs/working/gitea-close-2026-07-23/comment-54.md
new file mode 100644
index 0000000..5cde62a
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-54.md
@@ -0,0 +1,5 @@
+**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
+
+Inhalt identisch mit **#55** (Placeholder Registry: UNRESOLVED & TO_VERIFY Metadaten).
+
+Bitte weiterverfolgen unter #55.
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-57.md b/.claude/docs/working/gitea-close-2026-07-23/comment-57.md
new file mode 100644
index 0000000..0d7fed1
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-57.md
@@ -0,0 +1,5 @@
+**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
+
+Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
+
+Bitte weiterverfolgen unter #56.
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-58.md b/.claude/docs/working/gitea-close-2026-07-23/comment-58.md
new file mode 100644
index 0000000..0d7fed1
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-58.md
@@ -0,0 +1,5 @@
+**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
+
+Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
+
+Bitte weiterverfolgen unter #56.
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-65.md b/.claude/docs/working/gitea-close-2026-07-23/comment-65.md
new file mode 100644
index 0000000..804aea4
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-65.md
@@ -0,0 +1,9 @@
+**Automatischer Audit (Code + Playwright, 2026-07-23)**
+
+Nutzer-konfigurierbares Dashboard umgesetzt:
+- Migration `039_dashboard_layout.sql` — `profiles.dashboard_layout`
+- API: `backend/routers/app_dashboard.py` — GET/PUT/reset `/api/app/dashboard-layout`
+- Frontend: `DashboardConfigurePage.jsx`, Widget-Registry `registerDashboardWidgets.js`
+- Tests: `test_dashboard_layout_schema.py`, `test_widget_catalog.py`
+
+Playwright: `/settings/dashboard-layout` erreichbar, Widget/Layout-UI sichtbar.
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-66.md b/.claude/docs/working/gitea-close-2026-07-23/comment-66.md
new file mode 100644
index 0000000..a76b300
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-66.md
@@ -0,0 +1,9 @@
+**Automatischer Audit (Code, 2026-07-23)**
+
+Feature-Gate-Zuordnung aus Admin/DB implementiert:
+- Migration `041_widget_feature_requirements.sql`
+- Logik: `backend/widget_feature_requirements_db.py`, `dashboard_widget_entitlements.py`
+- Admin-UI: `AdminWidgetFeatureAssignmentsPage.jsx`, Route `/admin/widget-features`
+- Changelog: `backend/version.py` (Admin Widgets × Features)
+
+Hardcodierter Katalog wird durch DB-Overrides ergänzt (Hybrid-Modell).
diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-71.md b/.claude/docs/working/gitea-close-2026-07-23/comment-71.md
new file mode 100644
index 0000000..b586fe6
--- /dev/null
+++ b/.claude/docs/working/gitea-close-2026-07-23/comment-71.md
@@ -0,0 +1,20 @@
+**Implementierung abgeschlossen (2026-07-24)**
+
+### 1. Admin „Format prüfen“ inkl. `import_row_processing`
+- `AdminCsvTemplateEditorPage`: `resolveEditorImportRowProcessing()` — dieselbe Spec wie beim Speichern
+- Dry-Run/Format-Check nutzt konsistente Vorlage inkl. Aggregation (`group_by` / `aggregates`)
+
+### 2. Profil-Mappings validieren
+- `csv_import.py`: `_validate_mapping_config` / `_ensure_mapping_valid` bei **Copy** und **Import**
+- Neuer Endpoint: `GET /api/csv/mappings/{id}/validate`
+- `api.js`: `validateCsvMapping()`; `formatFastApiDetail` für verschachtelte Validierungsfehler
+
+### 3. Nutzer-UI: strukturierte Fehler
+- Neue Komponente `CsvImportErrorDetails.jsx` (Zeile, code, hint)
+- `UniversalCsvImportPage` zeigt `error_details` statt JSON-Dump
+
+### Tests & Doku
+- `backend/tests/test_csv_mapping_validation.py` — pytest grün
+- Agent-Guide: `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` §4 aktualisiert
+
+**Betroffene Dateien:** `backend/routers/csv_import.py`, `frontend/src/pages/AdminCsvTemplateEditorPage.jsx`, `frontend/src/pages/UniversalCsvImportPage.jsx`, `frontend/src/components/CsvImportErrorDetails.jsx`, `frontend/src/utils/api.js`
diff --git a/backend/routers/activity.py b/backend/routers/activity.py
index da3e3ef..94f40b9 100644
--- a/backend/routers/activity.py
+++ b/backend/routers/activity.py
@@ -617,6 +617,24 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
Persistenz läuft über activity_persistence_orchestrator — gleiche Schicht wie Universal-CSV.
"""
pid = get_pid(x_profile_id)
+
+ # Feature-Enforcement (wie manueller Create + nutrition/import-csv): activity_entries
+ access = check_feature_access(pid, "activity_entries")
+ log_feature_usage(pid, "activity_entries", access, "import_csv")
+ if not access["allowed"]:
+ logger.warning(
+ f"[FEATURE-LIMIT] User {pid} blocked: "
+ f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
+ )
+ raise HTTPException(
+ status_code=403,
+ detail=(
+ f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten "
+ f"({access['used']}/{access['limit']}). "
+ f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
+ ),
+ )
+
raw = await file.read()
try: text = raw.decode('utf-8')
except: text = raw.decode('latin-1')
@@ -729,4 +747,8 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
except Exception as e:
logger.warning(f"Import row failed: {e}")
skipped+=1
+
+ for _ in range(inserted):
+ increment_feature_usage(pid, "activity_entries")
+
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
diff --git a/backend/routers/csv_import.py b/backend/routers/csv_import.py
index c6dc535..b5ccfb3 100644
--- a/backend/routers/csv_import.py
+++ b/backend/routers/csv_import.py
@@ -35,6 +35,7 @@ from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapp
from csv_parser.field_units import source_unit_choices_for_field
from csv_parser.import_errors import enrich_row_error
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
+from csv_parser.template_validator import validate_csv_template
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
@@ -50,6 +51,38 @@ def _load_import_limits() -> dict[str, int]:
return get_csv_import_limits(r2d(row) if row else None)
+def _mapping_column_signature(m: dict) -> list[str] | None:
+ sig = m.get("column_signature")
+ if not sig:
+ return None
+ return list(sig)
+
+
+def _validate_mapping_config(cur, m: dict) -> dict:
+ """Strukturelle Vorlagen-Prüfung (wie Admin validate / Create)."""
+ return validate_csv_template(
+ m["module"],
+ m.get("field_mappings") or {},
+ m.get("type_conversions"),
+ m.get("import_row_processing"),
+ _mapping_column_signature(m),
+ cur=cur,
+ )
+
+
+def _ensure_mapping_valid(cur, m: dict) -> dict:
+ report = _validate_mapping_config(cur, m)
+ if not report.get("valid"):
+ raise HTTPException(
+ status_code=422,
+ detail={
+ "message": "CSV-Vorlage ist strukturell ungültig.",
+ "validation": report,
+ },
+ )
+ return report
+
+
def _mapping_to_summary(m: dict) -> dict:
return {
"id": m["id"],
@@ -184,6 +217,16 @@ def copy_csv_mapping(
n += 1
name = f"{base_name} {n}"
+ validation = _validate_mapping_config(cur, src)
+ if not validation.get("valid"):
+ raise HTTPException(
+ status_code=422,
+ detail={
+ "message": "Quell-Vorlage ist ungültig — Kopie abgebrochen.",
+ "validation": validation,
+ },
+ )
+
cur.execute(
"""
INSERT INTO csv_field_mappings (
@@ -213,7 +256,21 @@ def copy_csv_mapping(
),
)
new_id = cur.fetchone()["id"]
- return {"new_mapping_id": new_id, "mapping_name": name}
+ return {"new_mapping_id": new_id, "mapping_name": name, "validation": validation}
+
+
+@router.get("/mappings/{mapping_id}/validate")
+def validate_csv_mapping(
+ mapping_id: int,
+ session: dict = Depends(require_auth),
+):
+ """Strukturprüfung einer gespeicherten Vorlage (System oder eigenes Profil-Mapping)."""
+ pid = str(session["profile_id"])
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ m = _fetch_mapping_row(cur, mapping_id, pid)
+ report = _validate_mapping_config(cur, m)
+ return {"mapping_id": mapping_id, "mapping_name": m.get("mapping_name"), **report}
@router.post("/analyze")
@@ -538,6 +595,7 @@ async def csv_import_execute(
)
_check_module_feature_access(pid, exec_module)
+ _ensure_mapping_valid(cur, m)
cur.execute(
"""
diff --git a/backend/tests/test_activity_import_feature_enforcement.py b/backend/tests/test_activity_import_feature_enforcement.py
new file mode 100644
index 0000000..aa7eade
--- /dev/null
+++ b/backend/tests/test_activity_import_feature_enforcement.py
@@ -0,0 +1,107 @@
+"""Feature-Enforcement für Legacy Activity CSV-Import (#37)."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from fastapi import HTTPException
+
+from routers.activity import import_activity_csv
+
+
+@pytest.mark.asyncio
+async def test_import_activity_csv_blocks_when_limit_reached():
+ upload = AsyncMock()
+ upload.read = AsyncMock(return_value=b"Workout Type,Start\n")
+
+ with patch("routers.activity.get_pid", return_value="profile-1"), patch(
+ "routers.activity.check_feature_access",
+ return_value={
+ "allowed": False,
+ "reason": "limit_exceeded",
+ "used": 30,
+ "limit": 30,
+ },
+ ), patch("routers.activity.log_feature_usage"):
+ with pytest.raises(HTTPException) as exc:
+ await import_activity_csv(
+ file=upload,
+ x_profile_id=None,
+ session={"profile_id": "profile-1"},
+ )
+ assert exc.value.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_import_activity_csv_increments_usage_per_insert(monkeypatch):
+ upload = AsyncMock()
+ upload.read = AsyncMock(
+ return_value=(
+ "Workout Type,Start,Duration,Aktive Energie (kJ),Ruheeinträge (kJ),"
+ "Durchschn. Herzfrequenz (count/min),Max. Herzfrequenz (count/min),Distanz (km),End\n"
+ "Running,2026-07-22 10:00:00 +0200,0:45:00,1000,500,140,160,5.0,\n"
+ ).encode("utf-8")
+ )
+
+ increments = []
+
+ class FakeCursor:
+ def execute(self, *args, **kwargs):
+ return None
+
+ def fetchone(self):
+ return None
+
+ class FakeConn:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+ fake_cur = FakeCursor()
+
+ monkeypatch.setattr("routers.activity.get_pid", lambda _h: "profile-1")
+ monkeypatch.setattr(
+ "routers.activity.check_feature_access",
+ lambda *_a, **_k: {"allowed": True, "reason": "ok", "used": 0, "limit": 100},
+ )
+ monkeypatch.setattr("routers.activity.log_feature_usage", lambda *_a, **_k: None)
+ monkeypatch.setattr("routers.activity.get_db", lambda: FakeConn())
+ monkeypatch.setattr("routers.activity.get_cursor", lambda _c: fake_cur)
+ monkeypatch.setattr(
+ "routers.activity.normalize_activity_start",
+ lambda _s: ("2026-07-22", "10:00:00"),
+ )
+ monkeypatch.setattr(
+ "routers.activity.get_training_type_for_activity",
+ lambda *_a, **_k: (1, "cardio", None),
+ )
+ monkeypatch.setattr(
+ "routers.activity.find_activity_duplicate_id",
+ lambda *_a, **_k: None,
+ )
+ monkeypatch.setattr(
+ "routers.activity.new_activity_id",
+ lambda: "new-id",
+ )
+ monkeypatch.setattr(
+ "routers.activity.insert_activity_csv_minimal",
+ lambda *_a, **_k: None,
+ )
+ monkeypatch.setattr(
+ "routers.activity.run_activity_post_write_hooks_import",
+ lambda *_a, **_k: None,
+ )
+ monkeypatch.setattr(
+ "routers.activity.increment_feature_usage",
+ lambda _pid, feature: increments.append(feature),
+ )
+
+ result = await import_activity_csv(
+ file=upload,
+ x_profile_id=None,
+ session={"profile_id": "profile-1"},
+ )
+
+ assert result["inserted"] == 1
+ assert increments == ["activity_entries"]
diff --git a/backend/tests/test_csv_mapping_validation.py b/backend/tests/test_csv_mapping_validation.py
new file mode 100644
index 0000000..3c90b35
--- /dev/null
+++ b/backend/tests/test_csv_mapping_validation.py
@@ -0,0 +1,26 @@
+"""Tests für CSV-Vorlagen-Validierung (#71)."""
+
+from csv_parser.template_validator import validate_csv_template
+
+
+def test_validate_csv_template_rejects_invalid_row_processing():
+ report = validate_csv_template(
+ "nutrition",
+ {"Datum": "date", "kcal": "kcal"},
+ None,
+ {"group_by": ["date"], "aggregates": {"kcal": "not_an_op"}},
+ ["Datum", "kcal"],
+ )
+ assert report["valid"] is False
+ assert any(e.get("code") == "invalid_import_row_processing" for e in report["errors"])
+
+
+def test_validate_csv_template_accepts_nutrition_aggregation():
+ report = validate_csv_template(
+ "nutrition",
+ {"Datum": "date", "kcal": "kcal", "protein": "protein_g"},
+ None,
+ {"group_by": ["date"], "aggregates": {"kcal": "sum", "protein_g": "sum"}},
+ ["Datum", "kcal", "protein"],
+ )
+ assert report["valid"] is True
diff --git a/backend/version.py b/backend/version.py
index e2fa4c9..ea24d89 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
- PATCH: Bugfix, kleine Änderung, Refactor
"""
-APP_VERSION = "0.9t"
-BUILD_DATE = "2026-04-20"
+APP_VERSION = "0.9u"
+BUILD_DATE = "2026-07-24"
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
MODULE_VERSIONS = {
@@ -19,8 +19,8 @@ MODULE_VERSIONS = {
"weight": "1.0.3",
"circumference": "1.0.1",
"caliper": "1.0.1",
- "activity": "1.2.0", # GET /activity: optional days= window + limit
- "nutrition": "1.0.2",
+ "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
+ "nutrition": "1.0.3", # FDDB import UI: UsageBadge + blocked import zone
"photos": "1.0.0",
"insights": "1.3.0",
"prompts": "1.1.0",
@@ -31,11 +31,20 @@ MODULE_VERSIONS = {
"membership": "2.1.0",
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
- "csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
- "admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
+ "csv_import": "0.4.0", # Mapping validation on copy/import; validate endpoint; error_details UI
+ "admin_csv_templates": "0.3.1", # Format check includes import_row_processing parity
}
CHANGELOG = [
+ {
+ "version": "0.9u",
+ "date": "2026-07-24",
+ "changes": [
+ "Gitea #37: Activity legacy CSV import — activity_entries feature enforcement + UsageBadge UI",
+ "Gitea #38: Nutrition FDDB import — UsageBadge + deaktivierte Import-Zone bei Limit",
+ "Gitea #71: Universal CSV — Dry-Run/Format-Check Parität import_row_processing; Mapping-Validierung bei Copy/Import; strukturierte error_details in Nutzer-UI",
+ ],
+ },
{
"version": "0.9t",
"date": "2026-04-20",
diff --git a/frontend/src/components/CsvImportErrorDetails.jsx b/frontend/src/components/CsvImportErrorDetails.jsx
new file mode 100644
index 0000000..26209f4
--- /dev/null
+++ b/frontend/src/components/CsvImportErrorDetails.jsx
@@ -0,0 +1,76 @@
+/**
+ * Strukturierte Zeilenfehler aus Universal-CSV-Import (error_details mit hint/code).
+ */
+export default function CsvImportErrorDetails({ errors, title, defaultOpen = true, maxVisible = 20 }) {
+ if (!errors?.length) return null
+
+ const shown = errors.slice(0, maxVisible)
+ const rest = errors.length - shown.length
+
+ return (
+
+ … und {rest} weitere Fehler (Details in der Import-Historie).
+
+ {title || `Zeilenfehler (${errors.length})`}
+
+
+ {shown.map((err, i) => {
+ const row = err.row ?? err.row_index
+ const message = err.error ?? err.message ?? (typeof err === 'string' ? err : JSON.stringify(err))
+ return (
+
+ {rest > 0 && (
+
+ {err.code}
+
+ )}
+
Health Auto Export App → Workouts exportieren → CSV → hier hochladen.
Nur die Workouts-…csv Datei wird benötigt (nicht die Detaildateien).
- Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht. + Inkl. Zeilenaggregation (wie beim Speichern). Warnungen blockieren nicht.
{validationReport.errors?.length ? ({e.code} : null}
{e.message}
{e.hint ? {e.hint} : null}
{w.code} : null}
{w.message}
{w.hint ? {w.hint} : null}
In FDDB: Mein Tagebuch → Exportieren → CSV — dann hier importieren.
@@ -498,23 +504,26 @@ function ImportPanel({ onImported }) { <> {/* Drag & Drop Zone */}