mitai-jinkendo/.claude/docs/architecture/FEATURE_ENFORCEMENT.md
Lars 7940dc7560 docs: Struktur .claude/docs versionieren, working/, Gitea-Index, Regeln
- .gitignore: .claude/docs, rules, commands tracken; settings.local weiter ignorieren
- DOCUMENTATION.md: verbindliche Ablage functional/technical/working/issues
- .claude/README.md: Agent-Einstieg; GITEA_ISSUES_INDEX aus MCP (Stand 2026-04-08)
- Arbeitspapiere von docs/ nach .claude/docs/working/ verschoben
- docs/MEMBERSHIP_SYSTEM.md als Stub; kanonisch technical/MEMBERSHIP_SYSTEM.md
- CLAUDE.md Pflichtlektüre und Links angepasst; docs/README.md vereinfacht

Made-with: Cursor
2026-04-08 13:01:49 +02:00

9.7 KiB

Feature Enforcement Pattern

Übersicht

Das Membership-System verwendet ein 4-Phasen-Modell für Feature-Limits:

  1. Phase 1: Cleanup - Feature-Konsolidierung, DB-Migration
  2. Phase 2: Non-blocking Monitoring - JSON-Logging, keine Blockierung
  3. Phase 3: Frontend Display - Usage-Badges, Quota-Übersicht
  4. Phase 4: Enforcement - Tatsächliche Blockierung bei Limit-Überschreitung

Status (2026-03-21): Alle 11 Features sind in Phase 4 (komplett implementiert)

Wie neue Features hinzufügen

1. Feature in Datenbank registrieren

INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit)
VALUES (
    'new_feature_id',      -- eindeutige ID
    'Feature Name',        -- Anzeigename
    'Beschreibung',        -- Beschreibung
    'data',                -- Kategorie: data, ai, export, integration
    'count',               -- limit_type: 'count' oder 'boolean'
    'monthly',             -- reset_period: 'never', 'daily', 'monthly'
    50                     -- default_limit: INT (NULL = unlimited, 0 = disabled)
);

limit_type:

  • count: Zählbare Features (z.B. 50 Einträge pro Monat)
  • boolean: An/Aus-Features (0 = disabled, 1 = enabled)

reset_period:

  • never: Counter reset sich nie (z.B. Lifetime-Limits)
  • daily: Reset um Mitternacht
  • monthly: Reset am 1. des Monats

2. Backend-Implementierung

a) Router-Endpoint anpassen

from auth import require_auth, check_feature_access, increment_feature_usage
from feature_logger import log_feature_usage

@router.post("/api/resource")
def create_resource(data: dict, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
    """Create new resource entry."""
    pid = get_pid(x_profile_id)

    # Phase 4: Check feature access and ENFORCE
    access = check_feature_access(pid, 'new_feature_id')
    log_feature_usage(pid, 'new_feature_id', access, 'create')

    if not access['allowed']:
        logger.warning(
            f"[FEATURE-LIMIT] User {pid} blocked: "
            f"new_feature_id {access['reason']} (used: {access['used']}, limit: {access['limit']})"
        )
        raise HTTPException(
            status_code=403,
            detail=f"Limit erreicht: Du hast das Kontingent für [Feature-Name] überschritten ({access['used']}/{access['limit']}). "
                   f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
        )

    # ... Business Logic: INSERT into DB ...

    # Phase 2: Increment usage counter (only for NEW entries, not updates!)
    increment_feature_usage(pid, 'new_feature_id')

    return {"id": resource_id}

b) Wichtige Regeln

Counter nur bei INSERT, nicht bei UPDATE:

cur.execute("SELECT id FROM resource WHERE profile_id=%s AND date=%s", (pid, date))
existing = cur.fetchone()

if existing:
    # UPDATE - NICHT incrementieren
    cur.execute("UPDATE resource SET ... WHERE id=%s", (..., existing['id']))
else:
    # INSERT - incrementieren
    cur.execute("INSERT INTO resource (...) VALUES (...)", (...))
    increment_feature_usage(pid, 'new_feature_id')  # ← Nur hier!

Für Bulk-Operationen (CSV-Import):

new_entries = 0
for row in csv_data:
    cur.execute("SELECT id FROM resource WHERE profile_id=%s AND date=%s", (pid, row['date']))
    if not cur.fetchone():
        # INSERT
        cur.execute("INSERT INTO resource (...) VALUES (...)", (...))
        new_entries += 1

# Increment counter für alle neuen Einträge
for _ in range(new_entries):
    increment_feature_usage(pid, 'new_feature_id')

3. Frontend-Implementierung

a) Import UsageBadge

import UsageBadge from '../components/UsageBadge'

b) State Management

export default function ResourcePage() {
  const [saving, setSaving] = useState(false)
  const [saved,  setSaved]  = useState(false)
  const [error,  setError]  = useState(null)
  const [resourceUsage, setResourceUsage] = useState(null)  // Phase 4

  const load = () => api.listResources().then(setResources)

  const loadUsage = () => {
    api.getFeatureUsage().then(features => {
      const feature = features.find(f => f.feature_id === 'new_feature_id')
      setResourceUsage(feature)
    }).catch(err => console.error('Failed to load usage:', err))
  }

  useEffect(() => {
    load()
    loadUsage()
  }, [])

  const handleSave = async () => {
    setSaving(true)
    setError(null)
    try {
      await api.createResource(data)
      setSaved(true)
      await load()
      await loadUsage()  // Reload usage nach save
      setTimeout(() => setSaved(false), 2000)
    } catch (err) {
      console.error('Save failed:', err)
      setError(err.message || 'Fehler beim Speichern')
      setTimeout(() => setError(null), 5000)
    } finally {
      setSaving(false)
    }
  }

c) UI mit Badge und deaktiviertem Button

  return (
    <div className="card">
      {/* Titel mit Badge */}
      <div className="card-title badge-container-right">
        <span>Neuer Eintrag</span>
        {resourceUsage && <UsageBadge {...resourceUsage} />}
      </div>

      {/* Error-Meldung */}
      {error && (
        <div style={{
          padding:'10px',
          background:'var(--danger-bg)',
          border:'1px solid var(--danger)',
          borderRadius:8,
          fontSize:13,
          color:'var(--danger)',
          marginBottom:12
        }}>
          {error}
        </div>
      )}

      {/* Button mit Tooltip-Wrapper (disabled buttons zeigen keine nativen tooltips!) */}
      <div
        title={resourceUsage && !resourceUsage.allowed
          ? `Limit erreicht (${resourceUsage.used}/${resourceUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.`
          : ''}
        style={{display:'inline-block', width:'100%'}}
      >
        <button
          className="btn btn-primary btn-full"
          onClick={handleSave}
          disabled={saving || !data || (resourceUsage && !resourceUsage.allowed)}
          style={{cursor: (resourceUsage && !resourceUsage.allowed) ? 'not-allowed' : 'pointer'}}
        >
          {saved ? <><Check size={15}/> Gespeichert!</>
                 : saving ? <><div className="spinner" style={{width:14,height:14}}/> </>
                 : (resourceUsage && !resourceUsage.allowed) ? '🔒 Limit erreicht'
                 : 'Speichern'}
        </button>
      </div>
    </div>
  )

d) CSS-Styling (bereits vorhanden)

Die Badge-Styles sind zentral in frontend/src/components/UsageBadge.css:

/* Badge container rechts-aligniert */
.badge-container-right {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

/* Badge selbst */
.usage-badge {
  font-size: 0.65rem;
  font-weight: 600;
  padding: 2px 6px;
  border-radius: 4px;
  opacity: 0.6;
  white-space: nowrap;
}

.usage-badge--ok       { color: #888; background: #f0f0f0; }
.usage-badge--warning  { color: #EF9F27; background: #FFF4E5; }
.usage-badge--exceeded { color: #D85A30; background: #FCEBEB; }

4. Admin-UI (optional)

Wenn das Feature tier-spezifische Limits braucht, in Admin-UI ergänzen:

// frontend/src/pages/admin/TierLimitsPage.jsx
const FEATURE_OPTIONS = [
  { value: 'new_feature_id', label: 'Feature Name' },
  // ... existing features
]

Checkliste für neue Features

  • Feature in features Tabelle registriert
  • Backend: check_feature_access() + log_feature_usage() vor Business Logic
  • Backend: raise HTTPException(403, ...) wenn !access['allowed']
  • Backend: increment_feature_usage() nach INSERT (nicht bei UPDATE!)
  • Frontend: UsageBadge importiert
  • Frontend: resourceUsage State mit loadUsage()
  • Frontend: Badge im Titel (badge-container-right)
  • Frontend: Button deaktiviert bei !resourceUsage.allowed
  • Frontend: Tooltip-Wrapper um Button (<div title="...">)
  • Frontend: Error-Handling in handleSave() mit catch
  • Frontend: loadUsage() nach erfolgreichem Speichern
  • Feature-ID in CLAUDE.md dokumentiert (falls relevant)

Bestehende Features (Referenz)

Feature ID Typ Reset Beschreibung
weight_entries count never Gewichtseinträge
circumference_entries count never Umfangseinträge
caliper_entries count never Caliper-Messungen
activity_entries count monthly Aktivitätseinträge
nutrition_entries count monthly Ernährungseinträge (meist CSV-Import)
photos count monthly Progress-Fotos
ai_calls count monthly KI-Analysen (Einzelanalysen)
ai_pipeline boolean - KI-Pipeline (An/Aus)
data_export count monthly Daten-Exporte (CSV/JSON/ZIP)
data_import count monthly Daten-Importe (ZIP)

Debugging

Log-Datei prüfen:

tail -f backend/logs/feature-usage.log | jq .

Struktur:

{
  "timestamp": "2026-03-21T14:23:45.123456",
  "profile_id": "uuid",
  "feature_id": "weight_entries",
  "action": "create",
  "allowed": true,
  "used": 7,
  "limit": 50,
  "remaining": 43,
  "reason": "within_limit"
}

Datenbank prüfen:

-- Aktuelle Usage
SELECT * FROM user_feature_usage WHERE profile_id = 'xxx';

-- Feature-Definition
SELECT * FROM features WHERE id = 'new_feature_id';

-- Tier-Limits
SELECT * FROM tier_limits WHERE feature_id = 'new_feature_id';

-- User-Overrides
SELECT * FROM user_feature_restrictions
WHERE profile_id = 'xxx' AND feature_id = 'new_feature_id';

Weiterführende Dokumentation

  • Membership-System: .claude/docs/technical/MEMBERSHIP_SYSTEM.md
  • Backend-Architektur: .claude/docs/architecture/BACKEND.md
  • Frontend-Architektur: .claude/docs/architecture/FRONTEND.md
  • Coding Rules: .claude/docs/rules/CODING_RULES.md