Enhance Club Feature Consumption Logic and Update Versioning
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m40s
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m40s
- Introduced the `consume_club_feature_with_usage` function to standardize feature consumption across endpoints, improving code reusability and clarity. - Implemented `merge_feature_usage_into_response` to embed feature usage data in API responses, streamlining frontend integration. - Updated various backend routers to utilize the new consumption logic, ensuring consistent feature usage tracking during AI-related actions. - Enhanced tests to validate the new consumption and logging behavior. - Incremented application version to 0.8.199 and updated module version for 'club_features' to 1.6.0 to reflect these changes.
This commit is contained in:
parent
40641594ac
commit
b68185842e
|
|
@ -331,10 +331,13 @@ def check_club_feature_access(
|
|||
5. assert_content_governance(...) # nur bei Objekt-Endpoints
|
||||
6. check_club_feature_access(club_id, feature_id)
|
||||
7. … Business-Logik …
|
||||
8. increment_club_feature_usage(club_id, feature_id) # nur bei INSERT / KI-Execute
|
||||
9. optional: log club_feature_usage_events (profile_id)
|
||||
8. consume_club_feature_with_usage(…) + merge_feature_usage_into_response(payload, usage)
|
||||
# Standard: zählen, JSON-Log phase=consume, feature_usage in Response
|
||||
9. optional: club_feature_usage_events (profile_id, action)
|
||||
```
|
||||
|
||||
**Response-Standard (alle Consume-Endpoints):** JSON-Feld `feature_usage` — Map `feature_id → { allowed, used, limit, remaining, reason, … }` wie `GET /me/entitlements`. Frontend: `request()` synchronisiert Entitlements automatisch (`featureUsageSync.js`); UI-Komponenten brauchen keinen Einzelcode.
|
||||
|
||||
### 7.4 Wer zählt als Verbrauch?
|
||||
|
||||
| Aktion | increment | Subjekt |
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
|
|||
Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block.
|
||||
Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment.
|
||||
|
||||
Verbrauch-Standard für Router:
|
||||
probe_club_feature_access → Business-Logik → consume_club_feature_with_usage → merge_feature_usage_into_response
|
||||
|
||||
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -457,6 +460,130 @@ def consume_club_feature(
|
|||
conn=conn,
|
||||
)
|
||||
|
||||
def _log_consume(connection) -> None:
|
||||
from club_feature_logger import log_club_feature_usage
|
||||
|
||||
access = check_club_feature_access(int(club_id), feature_id, conn=connection)
|
||||
log_club_feature_usage(
|
||||
club_id=int(club_id),
|
||||
profile_id=profile_id,
|
||||
feature_id=feature_id,
|
||||
action=action or "consume",
|
||||
access=access,
|
||||
phase="consume",
|
||||
)
|
||||
|
||||
if conn is not None:
|
||||
_log_consume(conn)
|
||||
else:
|
||||
with get_db() as c:
|
||||
_log_consume(c)
|
||||
|
||||
|
||||
def consume_club_feature_with_usage(
|
||||
*,
|
||||
feature_id: str,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
amount: int = 1,
|
||||
cur,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
conn=None,
|
||||
) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||
"""
|
||||
Standard nach erfolgreichem Verbrauch: zählen, protokollieren, Snapshot für Response.
|
||||
|
||||
Alle Endpoints mit Vereins-Kontingent-Verbrauch nutzen diese Funktion und
|
||||
``merge_feature_usage_into_response`` — kein duplizierter Einzelcode pro Route.
|
||||
"""
|
||||
consume_club_feature(
|
||||
feature_id=feature_id,
|
||||
club_id=club_id,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
action=action,
|
||||
amount=amount,
|
||||
conn=conn,
|
||||
)
|
||||
if club_id is None:
|
||||
return None
|
||||
return {
|
||||
feature_id: club_feature_usage_for_api(
|
||||
cur,
|
||||
club_id=int(club_id),
|
||||
feature_id=feature_id,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def merge_feature_usage_into_response(
|
||||
payload: Any,
|
||||
feature_usage: Optional[Dict[str, Dict[str, Any]]],
|
||||
) -> Any:
|
||||
"""Standard-Einbettung ``feature_usage`` in JSON-Responses."""
|
||||
if not feature_usage or not isinstance(payload, dict):
|
||||
return payload
|
||||
return {**payload, "feature_usage": feature_usage}
|
||||
|
||||
|
||||
def club_feature_usage_for_api(
|
||||
cur,
|
||||
*,
|
||||
club_id: int,
|
||||
feature_id: str,
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Feature-Zustand wie GET /me/entitlements → features[feature_id] (nach Verbrauch)."""
|
||||
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||
|
||||
db_conn = conn if conn is not None else cur.connection
|
||||
access = check_club_feature_access(int(club_id), feature_id, conn=db_conn)
|
||||
plan_id = access.get("plan_id") or get_effective_club_plan(cur, int(club_id))
|
||||
|
||||
if is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
feature_id=feature_id,
|
||||
tenant=tenant,
|
||||
):
|
||||
ex = quota_bypass_access(
|
||||
feature_id=feature_id,
|
||||
club_id=int(club_id),
|
||||
plan_id=plan_id,
|
||||
)
|
||||
reset_at = access.get("reset_at")
|
||||
return {
|
||||
"allowed": True,
|
||||
"used": access.get("used"),
|
||||
"limit": None,
|
||||
"remaining": None,
|
||||
"reason": ex.get("reason"),
|
||||
"platform_exempt": True,
|
||||
"reset_at": reset_at.isoformat() if hasattr(reset_at, "isoformat") else reset_at,
|
||||
}
|
||||
|
||||
return {
|
||||
"allowed": access.get("allowed"),
|
||||
"used": access.get("used"),
|
||||
"limit": access.get("limit"),
|
||||
"remaining": access.get("remaining"),
|
||||
"reason": access.get("reason"),
|
||||
"platform_exempt": False,
|
||||
"reset_at": access.get("reset_at").isoformat()
|
||||
if access.get("reset_at") is not None and hasattr(access.get("reset_at"), "isoformat")
|
||||
else access.get("reset_at"),
|
||||
}
|
||||
|
||||
|
||||
def increment_club_feature_usage(
|
||||
club_id: int,
|
||||
|
|
@ -508,8 +635,6 @@ def increment_club_feature_usage(
|
|||
(club_id, feature_id, profile_id, action or feature_id),
|
||||
)
|
||||
|
||||
c.commit()
|
||||
|
||||
if conn is not None:
|
||||
_run(conn)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,12 @@ from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContex
|
|||
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||
from account_lifecycle import assert_min_account_state
|
||||
from capabilities import probe_capability
|
||||
from club_features import consume_club_feature, probe_club_feature_access, resolve_club_id_for_probe
|
||||
from club_features import (
|
||||
consume_club_feature_with_usage,
|
||||
merge_feature_usage_into_response,
|
||||
probe_club_feature_access,
|
||||
resolve_club_id_for_probe,
|
||||
)
|
||||
|
||||
from exercise_rich_text import (
|
||||
RICH_HTML_EXERCISE_FIELDS,
|
||||
|
|
@ -2346,14 +2351,17 @@ def exercise_ai_suggest_endpoint(
|
|||
want_skills=body.include_skills,
|
||||
want_instructions=body.include_instructions,
|
||||
)
|
||||
consume_club_feature(
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="suggest",
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
payload = merge_feature_usage_into_response(payload, usage)
|
||||
return payload
|
||||
|
||||
|
||||
|
|
@ -2412,14 +2420,17 @@ def exercise_ai_regenerate_endpoint(
|
|||
want_skills=want_skills,
|
||||
want_instructions=want_instructions,
|
||||
)
|
||||
consume_club_feature(
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="regenerate",
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
payload = merge_feature_usage_into_response(payload, usage)
|
||||
return payload
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_pl
|
|||
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
||||
from account_lifecycle import assert_min_account_state
|
||||
from capabilities import probe_capability
|
||||
from club_features import consume_club_feature, probe_club_feature_access, resolve_club_id_for_probe
|
||||
from club_features import (
|
||||
consume_club_feature_with_usage,
|
||||
merge_feature_usage_into_response,
|
||||
probe_club_feature_access,
|
||||
resolve_club_id_for_probe,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
|
||||
|
||||
|
|
@ -42,14 +47,17 @@ def post_planning_exercise_suggest(
|
|||
cur = get_cursor(conn)
|
||||
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
|
||||
if uses_ai:
|
||||
consume_club_feature(
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="planning_suggest",
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
result = merge_feature_usage_into_response(result, usage)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -87,12 +95,15 @@ def post_progression_path_suggest(
|
|||
cur = get_cursor(conn)
|
||||
result = suggest_progression_path(cur, tenant=tenant, body=body)
|
||||
if uses_ai:
|
||||
consume_club_feature(
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="progression_path_suggest",
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
result = merge_feature_usage_into_response(result, usage)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from fastapi import HTTPException
|
|||
from club_features import (
|
||||
club_feature_enforcement_enabled,
|
||||
consume_club_feature,
|
||||
consume_club_feature_with_usage,
|
||||
merge_feature_usage_into_response,
|
||||
probe_club_feature_access,
|
||||
)
|
||||
|
||||
|
|
@ -94,6 +96,45 @@ def test_consume_skips_without_club_id(monkeypatch):
|
|||
assert calls == []
|
||||
|
||||
|
||||
def test_consume_logs_usage_after_increment(monkeypatch):
|
||||
logs = []
|
||||
|
||||
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass.is_club_feature_quota_bypassed",
|
||||
lambda *a, **k: False,
|
||||
)
|
||||
monkeypatch.setattr("club_features.increment_club_feature_usage", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
"club_features.check_club_feature_access",
|
||||
lambda club_id, feature_id, conn=None: {
|
||||
"allowed": True,
|
||||
"used": 1,
|
||||
"limit": 30,
|
||||
"remaining": 29,
|
||||
"plan_id": "club",
|
||||
"reason": "within_limit",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"club_feature_logger.log_club_feature_usage",
|
||||
lambda **kwargs: logs.append(kwargs),
|
||||
)
|
||||
|
||||
consume_club_feature(
|
||||
feature_id="ai_calls",
|
||||
club_id=5,
|
||||
profile_id=9,
|
||||
portal_role="trainer",
|
||||
action="suggest",
|
||||
conn=object(),
|
||||
)
|
||||
assert len(logs) == 1
|
||||
assert logs[0]["phase"] == "consume"
|
||||
assert logs[0]["feature_id"] == "ai_calls"
|
||||
assert logs[0]["club_id"] == 5
|
||||
|
||||
|
||||
def test_consume_increments_once_per_call(monkeypatch):
|
||||
calls = []
|
||||
|
||||
|
|
@ -106,6 +147,18 @@ def test_consume_increments_once_per_call(monkeypatch):
|
|||
lambda *a, **k: False,
|
||||
)
|
||||
monkeypatch.setattr("club_features.increment_club_feature_usage", _inc)
|
||||
monkeypatch.setattr(
|
||||
"club_features.check_club_feature_access",
|
||||
lambda club_id, feature_id, conn=None: {
|
||||
"allowed": True,
|
||||
"used": 1,
|
||||
"limit": 30,
|
||||
"remaining": 29,
|
||||
"plan_id": "club",
|
||||
"reason": "within_limit",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
|
||||
consume_club_feature(
|
||||
feature_id="ai_calls",
|
||||
club_id=5,
|
||||
|
|
@ -117,6 +170,40 @@ def test_consume_increments_once_per_call(monkeypatch):
|
|||
assert calls == [(5, "ai_calls", "suggest")]
|
||||
|
||||
|
||||
def test_merge_feature_usage_into_response():
|
||||
out = merge_feature_usage_into_response(
|
||||
{"ok": True},
|
||||
{"ai_calls": {"used": 3, "limit": 30}},
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out["feature_usage"]["ai_calls"]["used"] == 3
|
||||
assert merge_feature_usage_into_response({"x": 1}, None) == {"x": 1}
|
||||
|
||||
|
||||
def test_consume_with_usage_returns_snapshot(monkeypatch):
|
||||
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass.is_club_feature_quota_bypassed",
|
||||
lambda *a, **k: False,
|
||||
)
|
||||
monkeypatch.setattr("club_features.consume_club_feature", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"club_features.club_feature_usage_for_api",
|
||||
lambda cur, **kwargs: {"used": 4, "limit": 30, "allowed": True},
|
||||
)
|
||||
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=7,
|
||||
profile_id=1,
|
||||
portal_role="trainer",
|
||||
action="suggest",
|
||||
cur=_fake_cur(),
|
||||
conn=object(),
|
||||
)
|
||||
assert usage["ai_calls"]["used"] == 4
|
||||
|
||||
|
||||
def test_club_feature_enforcement_env_default_off(monkeypatch):
|
||||
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
|
||||
assert club_feature_enforcement_enabled() is False
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.197"
|
||||
APP_VERSION = "0.8.199"
|
||||
BUILD_DATE = "2026-06-07"
|
||||
DB_SCHEMA_VERSION = "20260606083"
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ MODULE_VERSIONS = {
|
|||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
|
||||
"admin_users": "1.0.0", # GET /api/admin/users
|
||||
"club_features": "1.5.0", # Kontingent-Bypass via Capability-Grants (probe/consume)
|
||||
"club_features": "1.6.0", # Standard consume_club_feature_with_usage + merge_feature_usage_into_response
|
||||
"club_quota_bypass": "1.0.0", # platform.club_quota.bypass* + Admin-Grants-API
|
||||
"admin_rights": "1.0.0", # M6: Rollen/Rechte — Capabilities, Bypass, Vereins-Kontingente
|
||||
"entitlements": "1.2.0", # capability_quota_bypass in Feature-Map für /me/entitlements
|
||||
|
|
@ -35,8 +35,8 @@ MODULE_VERSIONS = {
|
|||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||
"planning_exercise_suggest": "0.16.1", # M5: consume_club_feature ai_calls nach KI-Erfolg
|
||||
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
|
||||
"planning_exercise_suggest": "0.16.2", # feature_usage in KI-Responses nach consume
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
* Alle API-Aufrufe laufen über request() — siehe utils/api.js (Facade) und Domänenmodule (planning.js, exercises.js).
|
||||
*/
|
||||
|
||||
import { syncFeatureUsageFromApiResponse } from '../utils/featureUsageSync.js'
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
/** LocalStorage + Request-Header für Mandanten-Kontext */
|
||||
|
|
@ -80,7 +82,9 @@ async function _fetchWithAuth(endpoint, options = {}) {
|
|||
*/
|
||||
export async function request(endpoint, options = {}) {
|
||||
const response = await _fetchWithAuth(endpoint, options)
|
||||
return response.json()
|
||||
const data = await response.json()
|
||||
syncFeatureUsageFromApiResponse(data)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Text-Download (z. B. CSV-Export) mit gleicher Auth wie request(). */
|
||||
|
|
|
|||
|
|
@ -4,10 +4,23 @@ import {
|
|||
getDefaultClubIdForGovernanceForms,
|
||||
getResolvedActiveClubIdForUi,
|
||||
} from '../utils/activeClub'
|
||||
import {
|
||||
registerFeatureUsageSyncHandler,
|
||||
unregisterFeatureUsageSyncHandler,
|
||||
} from '../utils/featureUsageSync'
|
||||
import { useAuth } from './AuthContext'
|
||||
|
||||
const EntitlementsContext = createContext(null)
|
||||
|
||||
function mergeFeatureUsage(entitlements, featureUsage) {
|
||||
if (!entitlements || !featureUsage) return entitlements
|
||||
const features = { ...entitlements.features }
|
||||
for (const [fid, row] of Object.entries(featureUsage)) {
|
||||
if (row) features[fid] = { ...features[fid], ...row }
|
||||
}
|
||||
return { ...entitlements, features }
|
||||
}
|
||||
|
||||
export function EntitlementsProvider({ children }) {
|
||||
const { user, isAuthenticated, loading: authLoading } = useAuth()
|
||||
const [entitlements, setEntitlements] = useState(null)
|
||||
|
|
@ -38,6 +51,32 @@ export function EntitlementsProvider({ children }) {
|
|||
}
|
||||
}, [isAuthenticated, clubId])
|
||||
|
||||
const refreshEntitlementsQuiet = useCallback(async () => {
|
||||
if (!isAuthenticated) return null
|
||||
try {
|
||||
const data = await getMeEntitlements(clubId)
|
||||
setEntitlements(data)
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [isAuthenticated, clubId])
|
||||
|
||||
const applyFeatureUsageFromResponse = useCallback(
|
||||
async (apiResponse) => {
|
||||
if (apiResponse?.feature_usage) {
|
||||
setEntitlements((prev) => mergeFeatureUsage(prev, apiResponse.feature_usage))
|
||||
}
|
||||
return refreshEntitlementsQuiet()
|
||||
},
|
||||
[refreshEntitlementsQuiet],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
registerFeatureUsageSyncHandler(applyFeatureUsageFromResponse)
|
||||
return () => unregisterFeatureUsageSyncHandler()
|
||||
}, [applyFeatureUsageFromResponse])
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return
|
||||
refreshEntitlements()
|
||||
|
|
@ -49,10 +88,11 @@ export function EntitlementsProvider({ children }) {
|
|||
loading,
|
||||
error,
|
||||
refreshEntitlements,
|
||||
refreshEntitlementsQuiet,
|
||||
hasCapability: (capId) => Boolean(entitlements?.capabilities?.[capId]),
|
||||
getFeature: (featureId) => entitlements?.features?.[featureId] ?? null,
|
||||
}),
|
||||
[entitlements, loading, error, refreshEntitlements],
|
||||
[entitlements, loading, error, refreshEntitlements, refreshEntitlementsQuiet],
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
26
frontend/src/utils/featureUsageSync.js
Normal file
26
frontend/src/utils/featureUsageSync.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Zentraler Abgleich Vereins-Kontingente nach API-Responses.
|
||||
*
|
||||
* Backend-Standard: Jeder Endpoint mit Verbrauch liefert ``feature_usage``
|
||||
* (siehe club_features.consume_club_feature_with_usage). ``request()`` in
|
||||
* client.js ruft syncFeatureUsageFromApiResponse() — UI-Komponenten müssen
|
||||
* nichts Einzelnes tun.
|
||||
*/
|
||||
|
||||
let usageHandler = null
|
||||
|
||||
export function registerFeatureUsageSyncHandler(handler) {
|
||||
usageHandler = typeof handler === 'function' ? handler : null
|
||||
}
|
||||
|
||||
export function unregisterFeatureUsageSyncHandler() {
|
||||
usageHandler = null
|
||||
}
|
||||
|
||||
/** Wird von request() nach jedem erfolgreichen JSON-Response aufgerufen. */
|
||||
export function syncFeatureUsageFromApiResponse(data) {
|
||||
if (!data || typeof data !== 'object' || !data.feature_usage) return
|
||||
if (typeof usageHandler === 'function') {
|
||||
usageHandler(data)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user