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:
@@ -235,7 +235,7 @@ def create_session():
|
||||
return error_response(
|
||||
status=409,
|
||||
code="SESSION_LIMIT_EXCEEDED",
|
||||
message="Maximum active sessions limit reached (5). Please end an existing session first."
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -602,3 +602,111 @@ def get_history(session_id: str):
|
||||
code="HISTORY_ERROR",
|
||||
message="Failed to get conversation history"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_session(session_id: str):
|
||||
"""
|
||||
Permanently delete a game session.
|
||||
|
||||
This removes the session from the database entirely. The session cannot be
|
||||
recovered after deletion. Use this to free up session slots for users who
|
||||
have reached their tier limit.
|
||||
|
||||
Returns:
|
||||
200: Session deleted successfully
|
||||
401: Not authenticated
|
||||
404: Session not found or not owned by user
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Deleting session", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
|
||||
# Delete session (validates ownership internally)
|
||||
session_service = get_session_service()
|
||||
session_service.delete_session(session_id, user_id)
|
||||
|
||||
logger.info("Session deleted successfully",
|
||||
session_id=session_id,
|
||||
user_id=user_id)
|
||||
|
||||
return success_response({
|
||||
"message": "Session deleted successfully",
|
||||
"session_id": session_id
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found for deletion",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete session",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="SESSION_DELETE_ERROR",
|
||||
message="Failed to delete session"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/usage', methods=['GET'])
|
||||
@require_auth
|
||||
def get_usage():
|
||||
"""
|
||||
Get user's daily usage information.
|
||||
|
||||
Returns the current daily turn usage, limit, remaining turns,
|
||||
and reset time. Limits are based on user's subscription tier.
|
||||
|
||||
Returns:
|
||||
200: Usage information
|
||||
{
|
||||
"user_id": "user_123",
|
||||
"user_tier": "free",
|
||||
"current_usage": 15,
|
||||
"daily_limit": 50,
|
||||
"remaining": 35,
|
||||
"reset_time": "2025-11-27T00:00:00+00:00",
|
||||
"is_limited": false,
|
||||
"is_unlimited": false
|
||||
}
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Getting usage info")
|
||||
|
||||
try:
|
||||
# Get current user and tier
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
user_tier = get_user_tier_from_user(user)
|
||||
|
||||
# Get usage info from rate limiter
|
||||
rate_limiter = RateLimiterService()
|
||||
usage_info = rate_limiter.get_usage_info(user_id, user_tier)
|
||||
|
||||
logger.debug("Usage info retrieved",
|
||||
user_id=user_id,
|
||||
current_usage=usage_info.get('current_usage'),
|
||||
remaining=usage_info.get('remaining'))
|
||||
|
||||
return success_response(usage_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get usage info",
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="USAGE_ERROR",
|
||||
message="Failed to get usage information"
|
||||
)
|
||||
|
||||
@@ -76,6 +76,7 @@ class RateLimitTier:
|
||||
ai_calls_per_day: int
|
||||
custom_actions_per_day: int # -1 for unlimited
|
||||
custom_action_char_limit: int
|
||||
max_sessions: int = 1 # Maximum active game sessions allowed
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -458,6 +458,59 @@ class ChatMessageService:
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def delete_messages_by_session(self, session_id: str) -> int:
|
||||
"""
|
||||
Permanently delete all chat messages associated with a session.
|
||||
|
||||
Used when a session is deleted to clean up associated messages.
|
||||
This is a hard delete - messages are removed from the database entirely.
|
||||
|
||||
Args:
|
||||
session_id: Session ID whose messages should be deleted
|
||||
|
||||
Returns:
|
||||
Number of messages deleted
|
||||
|
||||
Note:
|
||||
This method does not validate ownership because it's called from
|
||||
session_service after ownership has already been validated.
|
||||
"""
|
||||
try:
|
||||
# Query all messages with this session_id
|
||||
messages = self.db.list_rows(
|
||||
table_id='chat_messages',
|
||||
queries=[Query.equal('session_id', session_id)],
|
||||
limit=1000 # Handle up to 1000 messages per session
|
||||
)
|
||||
|
||||
deleted_count = 0
|
||||
for message in messages:
|
||||
try:
|
||||
self.db.delete_document(
|
||||
collection_id='chat_messages',
|
||||
document_id=message.id
|
||||
)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.warning("Failed to delete individual message",
|
||||
message_id=message.id,
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
# Continue deleting other messages
|
||||
|
||||
logger.info("Deleted messages for session",
|
||||
session_id=session_id,
|
||||
deleted_count=deleted_count)
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete messages by session",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
# Don't raise - session deletion should still proceed
|
||||
return 0
|
||||
|
||||
# Helper Methods
|
||||
|
||||
def _update_recent_messages_preview(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,15 +19,13 @@ from app.services.database_service import get_database_service
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.services.chat_message_service import get_chat_message_service
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
# Session limits per user
|
||||
MAX_ACTIVE_SESSIONS = 5
|
||||
|
||||
|
||||
class SessionNotFound(Exception):
|
||||
"""Raised when session ID doesn't exist or user doesn't own it."""
|
||||
pass
|
||||
@@ -129,16 +127,22 @@ class SessionService:
|
||||
if not starting_location_type:
|
||||
starting_location_type = LocationType.TOWN
|
||||
|
||||
# Check session limit
|
||||
# Check session limit based on user's subscription tier
|
||||
user_tier = self.appwrite.get_user_tier(user_id)
|
||||
config = get_config()
|
||||
tier_config = config.rate_limiting.tiers.get(user_tier)
|
||||
max_sessions = tier_config.max_sessions if tier_config else 1
|
||||
|
||||
active_count = self.count_user_sessions(user_id, active_only=True)
|
||||
if active_count >= MAX_ACTIVE_SESSIONS:
|
||||
if active_count >= max_sessions:
|
||||
logger.warning("Session limit exceeded",
|
||||
user_id=user_id,
|
||||
tier=user_tier,
|
||||
current=active_count,
|
||||
limit=MAX_ACTIVE_SESSIONS)
|
||||
limit=max_sessions)
|
||||
raise SessionLimitExceeded(
|
||||
f"Maximum active sessions reached ({active_count}/{MAX_ACTIVE_SESSIONS}). "
|
||||
f"Please end an existing session to start a new one."
|
||||
f"Maximum active sessions reached for {user_tier} tier ({active_count}/{max_sessions}). "
|
||||
f"Please delete an existing session to start a new one."
|
||||
)
|
||||
|
||||
# Generate unique session ID
|
||||
@@ -409,6 +413,58 @@ class SessionService:
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def delete_session(self, session_id: str, user_id: str) -> bool:
|
||||
"""
|
||||
Permanently delete a session from the database.
|
||||
|
||||
Unlike end_session(), this method removes the session document entirely
|
||||
from the database. Use this when the user wants to free up their session
|
||||
slot and doesn't need to preserve the game history.
|
||||
|
||||
Also deletes all chat messages associated with this session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to delete
|
||||
user_id: User ID for ownership validation
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
|
||||
Raises:
|
||||
SessionNotFound: If session doesn't exist or user doesn't own it
|
||||
"""
|
||||
try:
|
||||
logger.info("Deleting session", session_id=session_id, user_id=user_id)
|
||||
|
||||
# Verify ownership first (raises SessionNotFound if invalid)
|
||||
self.get_session(session_id, user_id)
|
||||
|
||||
# Delete associated chat messages first
|
||||
chat_service = get_chat_message_service()
|
||||
deleted_messages = chat_service.delete_messages_by_session(session_id)
|
||||
logger.info("Deleted associated chat messages",
|
||||
session_id=session_id,
|
||||
message_count=deleted_messages)
|
||||
|
||||
# Delete session from database
|
||||
self.db.delete_document(
|
||||
collection_id=self.collection_id,
|
||||
document_id=session_id
|
||||
)
|
||||
|
||||
logger.info("Session deleted successfully",
|
||||
session_id=session_id,
|
||||
user_id=user_id)
|
||||
return True
|
||||
|
||||
except SessionNotFound:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete session",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_conversation_entry(
|
||||
self,
|
||||
session_id: str,
|
||||
|
||||
Reference in New Issue
Block a user