""" Social Service - Handles user follows and channel messages """ import logging import json from typing import List, Optional, Dict from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete, func from app.models.db.user_follow import UserFollow from app.models.db.celestial_body import CelestialBody from app.models.db.user import User from app.models.schemas.social import ChannelMessageResponse from app.services.redis_cache import redis_cache logger = logging.getLogger(__name__) class SocialService: def __init__(self): self.channel_message_prefix = "channel:messages:" self.channel_message_ttl_seconds = 7 * 24 * 60 * 60 # 7 days self.max_channel_messages = 500 # Max messages to keep in a channel # --- User Follows --- async def follow_body(self, user_id: int, body_id: str, db: AsyncSession) -> UserFollow: """User follows a celestial body""" # Check if already following existing_follow = await self.get_follow(user_id, body_id, db) if existing_follow: raise ValueError("Already following this body") # Check if body exists body = await db.execute(select(CelestialBody).where(CelestialBody.id == body_id)) if not body.scalar_one_or_none(): raise ValueError("Celestial body not found") follow = UserFollow(user_id=user_id, body_id=body_id) db.add(follow) await db.commit() await db.refresh(follow) logger.info(f"User {user_id} followed body {body_id}") return follow async def unfollow_body(self, user_id: int, body_id: str, db: AsyncSession) -> bool: """User unfollows a celestial body""" result = await db.execute( delete(UserFollow).where(UserFollow.user_id == user_id, UserFollow.body_id == body_id) ) await db.commit() if result.rowcount > 0: logger.info(f"User {user_id} unfollowed body {body_id}") return True return False async def get_follow(self, user_id: int, body_id: str, db: AsyncSession) -> Optional[UserFollow]: """Get a specific follow record""" result = await db.execute( select(UserFollow).where(UserFollow.user_id == user_id, UserFollow.body_id == body_id) ) return result.scalar_one_or_none() async def get_user_follows(self, user_id: int, db: AsyncSession) -> List[CelestialBody]: """Get all bodies followed by a user""" result = await db.execute( select(CelestialBody) .join(UserFollow, UserFollow.body_id == CelestialBody.id) .where(UserFollow.user_id == user_id) ) return result.scalars().all() async def get_user_follows_with_time(self, user_id: int, db: AsyncSession) -> List[dict]: """Get all bodies followed by a user with created_at time and body details""" from sqlalchemy.orm import selectinload result = await db.execute( select(UserFollow, CelestialBody) .join(CelestialBody, UserFollow.body_id == CelestialBody.id) .where(UserFollow.user_id == user_id) ) follows_with_bodies = result.all() return [ { "user_id": follow.user_id, "body_id": follow.body_id, "created_at": follow.created_at, # Keep as created_at to match schema "id": body.id, "name": body.name, "name_zh": body.name_zh, "type": body.type, "is_active": body.is_active, } for follow, body in follows_with_bodies ] async def get_body_followers_count(self, body_id: str, db: AsyncSession) -> int: """Get the number of followers for a celestial body""" result = await db.execute( select(func.count()).where(UserFollow.body_id == body_id) ) return result.scalar_one() # --- Channel Messages (Redis based) --- async def post_channel_message(self, user_id: int, body_id: str, content: str, db: AsyncSession) -> ChannelMessageResponse: """Post a message to a celestial body's channel""" # Verify user and body exist user_result = await db.execute(select(User.username).where(User.id == user_id)) username = user_result.scalar_one_or_none() if not username: raise ValueError("User not found") body_result = await db.execute(select(CelestialBody).where(CelestialBody.id == body_id)) if not body_result.scalar_one_or_none(): raise ValueError("Celestial body not found") # Verify user is following the body to post # According to the requirement, only followed users can post. is_following = await self.get_follow(user_id, body_id, db) if not is_following: raise ValueError("User is not following this celestial body channel") message_data = { "user_id": user_id, "username": username, "body_id": body_id, "content": content, "created_at": datetime.utcnow().isoformat() # Store as ISO string } channel_key = f"{self.channel_message_prefix}{body_id}" # Add message to the right of the list (newest) await redis_cache.rpush(channel_key, json.dumps(message_data)) # Store as JSON string # Trim list to max_channel_messages await redis_cache.ltrim(channel_key, -self.max_channel_messages, -1) # Set/reset TTL for the channel list (e.g., if no activity, it expires) await redis_cache.expire(channel_key, self.channel_message_ttl_seconds) logger.info(f"Message posted to channel {body_id} by user {user_id}") return ChannelMessageResponse(**message_data) async def get_channel_messages(self, body_id: str, db: AsyncSession, limit: int = 50) -> List[ChannelMessageResponse]: """Get recent messages from a celestial body's channel""" channel_key = f"{self.channel_message_prefix}{body_id}" # Get messages from Redis list (newest first for display) # LPUSH for oldest first, RPUSH for newest first. We use RPUSH, so -limit to -1 is newest raw_messages = await redis_cache.lrange(channel_key, -limit, -1) messages = [] for msg_str in raw_messages: try: msg_data = json.loads(msg_str) messages.append(ChannelMessageResponse(**msg_data)) except json.JSONDecodeError: logger.warning(f"Could not decode message from channel {body_id}: {msg_str}") return messages social_service = SocialService()