feat(api,web): tier-based session limits and daily turn usage display

Backend Changes:
- Add tier-based max_sessions config (free: 1, basic: 2, premium: 3, elite: 5)
- Add DELETE /api/v1/sessions/{id} endpoint for hard session deletion
- Cascade delete chat messages when session is deleted
- Add GET /api/v1/usage endpoint for daily turn limit info
- Replace hardcoded TIER_LIMITS with config-based ai_calls_per_day
- Handle unlimited (-1) tier in rate limiter service

Frontend Changes:
- Add inline session delete buttons with HTMX on character list
- Add usage_display.html component showing remaining daily turns
- Display usage indicator on character list and game play pages
- Page refresh after session deletion to update UI state

Documentation:
- Update API_REFERENCE.md with new endpoints and tier limits
- Update API_TESTING.md with session endpoint examples
- Update SESSION_MANAGEMENT.md with tier-based limits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-26 10:00:45 -06:00
parent 0a7156504f
commit 61a42d3a77
15 changed files with 768 additions and 59 deletions

View File

@@ -29,6 +29,7 @@ from typing import Optional
from app.services.redis_service import RedisService, RedisServiceError
from app.ai.model_selector import UserTier
from app.utils.logging import get_logger
from app.config import get_config
# Initialize logger
@@ -75,25 +76,13 @@ class RateLimiterService:
This service uses Redis to track daily AI usage per user and enforces
limits based on subscription tier. Counters reset daily at midnight UTC.
Tier Limits:
- Free: 20 turns/day
- Basic: 50 turns/day
- Premium: 100 turns/day
- Elite: 200 turns/day
Tier limits are loaded from config (rate_limiting.tiers.{tier}.ai_calls_per_day).
A value of -1 means unlimited.
Attributes:
redis: RedisService instance for counter storage
tier_limits: Mapping of tier to daily turn limit
"""
# Daily turn limits per tier
TIER_LIMITS = {
UserTier.FREE: 20,
UserTier.BASIC: 50,
UserTier.PREMIUM: 100,
UserTier.ELITE: 200,
}
# Daily DM question limits per tier
DM_QUESTION_LIMITS = {
UserTier.FREE: 10,
@@ -167,15 +156,27 @@ class RateLimiterService:
def get_limit_for_tier(self, user_tier: UserTier) -> int:
"""
Get the daily turn limit for a specific tier.
Get the daily turn limit for a specific tier from config.
Args:
user_tier: The user's subscription tier
Returns:
Daily turn limit for the tier
Daily turn limit for the tier (-1 means unlimited)
"""
return self.TIER_LIMITS.get(user_tier, self.TIER_LIMITS[UserTier.FREE])
config = get_config()
tier_name = user_tier.value.lower()
tier_config = config.rate_limiting.tiers.get(tier_name)
if tier_config:
return tier_config.ai_calls_per_day
# Fallback to default if tier not found in config
logger.warning(
"Tier not found in config, using default limit",
tier=tier_name
)
return 50 # Default fallback
def get_current_usage(self, user_id: str) -> int:
"""
@@ -227,9 +228,19 @@ class RateLimiterService:
RateLimitExceeded: If the user has reached their daily limit
RedisServiceError: If Redis operation fails
"""
current_usage = self.get_current_usage(user_id)
limit = self.get_limit_for_tier(user_tier)
# -1 means unlimited
if limit == -1:
logger.debug(
"Rate limit check passed (unlimited)",
user_id=user_id,
user_tier=user_tier.value
)
return
current_usage = self.get_current_usage(user_id)
if current_usage >= limit:
reset_time = self._get_reset_time()
@@ -308,11 +319,15 @@ class RateLimiterService:
user_tier: The user's subscription tier
Returns:
Number of turns remaining (0 if limit reached)
Number of turns remaining (-1 if unlimited, 0 if limit reached)
"""
current_usage = self.get_current_usage(user_id)
limit = self.get_limit_for_tier(user_tier)
# -1 means unlimited
if limit == -1:
return -1
current_usage = self.get_current_usage(user_id)
remaining = max(0, limit - current_usage)
logger.debug(
@@ -339,16 +354,25 @@ class RateLimiterService:
- user_id: User identifier
- user_tier: Subscription tier
- current_usage: Current daily usage
- daily_limit: Daily limit for tier
- remaining: Remaining turns
- daily_limit: Daily limit for tier (-1 means unlimited)
- remaining: Remaining turns (-1 if unlimited)
- reset_time: ISO format UTC reset time
- is_limited: Whether limit has been reached
- is_limited: Whether limit has been reached (always False if unlimited)
- is_unlimited: Whether user has unlimited turns
"""
current_usage = self.get_current_usage(user_id)
limit = self.get_limit_for_tier(user_tier)
remaining = max(0, limit - current_usage)
reset_time = self._get_reset_time()
# Handle unlimited tier (-1)
is_unlimited = (limit == -1)
if is_unlimited:
remaining = -1
is_limited = False
else:
remaining = max(0, limit - current_usage)
is_limited = current_usage >= limit
info = {
"user_id": user_id,
"user_tier": user_tier.value,
@@ -356,7 +380,8 @@ class RateLimiterService:
"daily_limit": limit,
"remaining": remaining,
"reset_time": reset_time.isoformat(),
"is_limited": current_usage >= limit
"is_limited": is_limited,
"is_unlimited": is_unlimited
}
logger.debug("Retrieved usage info", **info)