- .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
320 lines
9.7 KiB
Markdown
320 lines
9.7 KiB
Markdown
# 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
|
|
|
|
```sql
|
|
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
|
|
|
|
```python
|
|
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:**
|
|
```python
|
|
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):**
|
|
```python
|
|
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
|
|
|
|
```javascript
|
|
import UsageBadge from '../components/UsageBadge'
|
|
```
|
|
|
|
#### b) State Management
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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`:
|
|
|
|
```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:
|
|
|
|
```javascript
|
|
// 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:**
|
|
```bash
|
|
tail -f backend/logs/feature-usage.log | jq .
|
|
```
|
|
|
|
**Struktur:**
|
|
```json
|
|
{
|
|
"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:**
|
|
```sql
|
|
-- 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`
|