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

@@ -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(

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)

View File

@@ -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,