import json import logging from collections.abc import Mapping from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from sqlalchemy import text from sqlalchemy.orm import Session try: from app.db import get_db from app.security import get_current_user_id from app.schemas.im import ( ChildConversationItem, ChildConversationListResponse, ChildConversationMessageItem, ChildConversationMessageListResponse, ConversationMessageCreateResponse, ParentChildMessageCreateRequest, ) from app.service.im import create_parent_child_message except ModuleNotFoundError: from db import get_db from security import get_current_user_id from schemas.im import ( ChildConversationItem, ChildConversationListResponse, ChildConversationMessageItem, ChildConversationMessageListResponse, ConversationMessageCreateResponse, ParentChildMessageCreateRequest, ) from service.im import create_parent_child_message router = APIRouter(prefix="/children", tags=["im"]) logger = logging.getLogger("app.im") PARENT_PARTICIPANT_TYPE = 1 CHILD_PARTICIPANT_TYPE = 2 CHILD_PEER_CONVERSATION_TYPE = 1 PARENT_CHILD_CONVERSATION_TYPE = 2 SUPPORTED_CONVERSATION_TYPES = { CHILD_PEER_CONVERSATION_TYPE, PARENT_CHILD_CONVERSATION_TYPE, } CONVERSATION_TYPE_NAMES = { CHILD_PEER_CONVERSATION_TYPE: "child_peer", PARENT_CHILD_CONVERSATION_TYPE: "parent_child", } PARTICIPANT_TYPE_NAMES = { PARENT_PARTICIPANT_TYPE: "parent", CHILD_PARTICIPANT_TYPE: "child", } def _normalize_content_json(value: Any) -> dict[str, Any] | None: if value is None: return None if isinstance(value, dict): return value if isinstance(value, str): try: parsed = json.loads(value) if isinstance(parsed, dict): return parsed except json.JSONDecodeError: return None return None def _participant_type_name(participant_type: int) -> str: return PARTICIPANT_TYPE_NAMES.get(participant_type, f"unknown_{participant_type}") def _conversation_type_name(conversation_type: int) -> str: return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}") def _build_in_params(prefix: str, values: list[int]) -> tuple[str, dict[str, int]]: placeholders: list[str] = [] params: dict[str, int] = {} for index, value in enumerate(values): key = f"{prefix}_{index}" placeholders.append(f":{key}") params[key] = value return ", ".join(placeholders), params def _fetch_parent_names(db: Session, user_ids: set[int]) -> dict[int, str | None]: if not user_ids: return {} values = sorted(user_ids) placeholders, params = _build_in_params("user_id", values) rows = ( db.execute( text( f""" SELECT user_id, nickname FROM parents WHERE status = 1 AND user_id IN ({placeholders}) """ ), params, ) .mappings() .all() ) return {int(row["user_id"]): row["nickname"] for row in rows} def _fetch_child_names(db: Session, child_ids: set[int]) -> dict[int, str | None]: if not child_ids: return {} values = sorted(child_ids) placeholders, params = _build_in_params("child_id", values) rows = ( db.execute( text( f""" SELECT child_id, child_name FROM children WHERE status = 1 AND child_id IN ({placeholders}) """ ), params, ) .mappings() .all() ) return {int(row["child_id"]): row["child_name"] for row in rows} def _assert_child_access(db: Session, child_id: int, user_id: int) -> None: child_row = ( db.execute( text( """ SELECT child_id FROM children WHERE child_id = :child_id AND status = 1 LIMIT 1 """ ), {"child_id": child_id}, ) .mappings() .first() ) if not child_row: raise HTTPException(status_code=404, detail="child not found") has_access = ( db.execute( text( """ SELECT 1 FROM parent_child_relations WHERE user_id = :user_id AND child_id = :child_id AND status = 1 LIMIT 1 """ ), {"user_id": user_id, "child_id": child_id}, ).scalar_one_or_none() is not None ) if not has_access: raise HTTPException(status_code=403, detail="no permission to access this child") def _get_conversation_for_child( db: Session, *, conversation_id: int, child_id: int, ) -> Mapping[str, Any]: child_id_str = str(child_id) row = ( db.execute( text( """ SELECT id, conversation_type, participant_a_type, participant_a_id, participant_b_type, participant_b_id, status FROM im_conversations WHERE id = :conversation_id LIMIT 1 """ ), {"conversation_id": conversation_id}, ) .mappings() .first() ) if not row: raise HTTPException(status_code=404, detail="conversation not found") conversation_type = int(row["conversation_type"]) if conversation_type not in SUPPORTED_CONVERSATION_TYPES or int(row["status"]) != 1: raise HTTPException(status_code=404, detail="conversation not found") is_child_participant = ( ( int(row["participant_a_type"]) == CHILD_PARTICIPANT_TYPE and row["participant_a_id"] == child_id_str ) or ( int(row["participant_b_type"]) == CHILD_PARTICIPANT_TYPE and row["participant_b_id"] == child_id_str ) ) if not is_child_participant: raise HTTPException(status_code=404, detail="conversation not found") return row def _row_to_conversation_item( row: Mapping[str, Any], *, child_id: int, parent_names: Mapping[int, str | None], child_names: Mapping[int, str | None], ) -> ChildConversationItem: child_id_str = str(child_id) participant_a_type = int(row["participant_a_type"]) participant_b_type = int(row["participant_b_type"]) participant_a_id = str(row["participant_a_id"]) participant_b_id = str(row["participant_b_id"]) if participant_a_type == CHILD_PARTICIPANT_TYPE and participant_a_id == child_id_str: peer_type = participant_b_type peer_id = participant_b_id else: peer_type = participant_a_type peer_id = participant_a_id peer_name: str | None = None if peer_type == PARENT_PARTICIPANT_TYPE and peer_id.isdigit(): peer_name = parent_names.get(int(peer_id)) elif peer_type == CHILD_PARTICIPANT_TYPE and peer_id.isdigit(): peer_name = child_names.get(int(peer_id)) return ChildConversationItem( conversation_id=int(row["id"]), conversation_type=int(row["conversation_type"]), conversation_type_name=_conversation_type_name(int(row["conversation_type"])), peer_type=_participant_type_name(peer_type), peer_id=peer_id, peer_name=peer_name, last_message_preview=row["last_message_preview"], last_message_at=row["last_message_at"], message_count=int(row["message_count"]), ) def _row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem: return ChildConversationMessageItem( id=int(row["id"]), conversation_id=int(row["conversation_id"]), seq=int(row["seq"]), sender_type=_participant_type_name(int(row["sender_type"])), sender_id=str(row["sender_id"]), receiver_type=_participant_type_name(int(row["receiver_type"])), receiver_id=str(row["receiver_id"]), content_type=int(row["content_type"]), content_text=row["content_text"], content_json=_normalize_content_json(row["content_json"]), media_file_key=row["media_file_key"], media_duration_ms=row["media_duration_ms"], media_mime_type=row.get("media_mime_type"), media_size_bytes=row.get("media_size_bytes"), media_transcript_text=row.get("media_transcript_text"), client_msg_id=row["client_msg_id"], sender_name_snapshot=row["sender_name_snapshot"], sender_avatar_snapshot=row.get("sender_avatar_snapshot"), receiver_name_snapshot=row["receiver_name_snapshot"], receiver_avatar_snapshot=row.get("receiver_avatar_snapshot"), ext_json=_normalize_content_json(row.get("ext_json")), created_at=row["created_at"], ) @router.get("/{child_id}/conversations", response_model=ChildConversationListResponse) def list_child_conversations( child_id: int, request: Request, conversation_type: int | None = Query(default=None), cursor: int | None = Query(default=None, ge=1), limit: int = Query(default=20, ge=1, le=100), current_user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db), ) -> ChildConversationListResponse: if conversation_type is not None and conversation_type not in SUPPORTED_CONVERSATION_TYPES: raise HTTPException(status_code=422, detail="unsupported conversation_type") _assert_child_access(db=db, child_id=child_id, user_id=current_user_id) params: dict[str, Any] = { "child_id_str": str(child_id), "child_participant_type": CHILD_PARTICIPANT_TYPE, "child_peer_conversation_type": CHILD_PEER_CONVERSATION_TYPE, "parent_child_conversation_type": PARENT_CHILD_CONVERSATION_TYPE, "fetch_limit": limit + 1, } where = """ status = 1 AND conversation_type IN (:child_peer_conversation_type, :parent_child_conversation_type) AND ( (participant_a_type = :child_participant_type AND participant_a_id = :child_id_str) OR (participant_b_type = :child_participant_type AND participant_b_id = :child_id_str) ) """ if conversation_type is not None: where += " AND conversation_type = :conversation_type" params["conversation_type"] = conversation_type if cursor is not None: where += " AND id < :cursor" params["cursor"] = cursor rows = ( db.execute( text( f""" SELECT id, conversation_type, participant_a_type, participant_a_id, participant_b_type, participant_b_id, last_message_preview, last_message_at, message_count, created_at FROM im_conversations WHERE {where} ORDER BY COALESCE(last_message_at, created_at) DESC, id DESC LIMIT :fetch_limit """ ), params, ) .mappings() .all() ) has_more = len(rows) > limit rows = rows[:limit] next_cursor = int(rows[-1]["id"]) if has_more and rows else None peer_parent_ids: set[int] = set() peer_child_ids: set[int] = set() child_id_str = str(child_id) for row in rows: participant_a_type = int(row["participant_a_type"]) participant_b_type = int(row["participant_b_type"]) participant_a_id = str(row["participant_a_id"]) participant_b_id = str(row["participant_b_id"]) if participant_a_type == CHILD_PARTICIPANT_TYPE and participant_a_id == child_id_str: peer_type = participant_b_type peer_id = participant_b_id else: peer_type = participant_a_type peer_id = participant_a_id if peer_id.isdigit(): if peer_type == PARENT_PARTICIPANT_TYPE: peer_parent_ids.add(int(peer_id)) elif peer_type == CHILD_PARTICIPANT_TYPE: peer_child_ids.add(int(peer_id)) parent_names = _fetch_parent_names(db, peer_parent_ids) child_names = _fetch_child_names(db, peer_child_ids) items = [ _row_to_conversation_item( row, child_id=child_id, parent_names=parent_names, child_names=child_names, ) for row in rows ] logger.info( "child conversations listed", extra={ "event": "child_conversation_list", "request_id": getattr(request.state, "request_id", None), "user_id": current_user_id, "child_id": child_id, "conversation_type": conversation_type, "count": len(items), }, ) return ChildConversationListResponse( items=items, total=len(items), next_cursor=next_cursor, ) @router.post( "/{child_id}/messages", response_model=ConversationMessageCreateResponse, status_code=status.HTTP_201_CREATED, ) def create_child_message_for_parent( child_id: int, payload: ParentChildMessageCreateRequest, request: Request, response: Response, current_user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db), ) -> ConversationMessageCreateResponse: result = create_parent_child_message( db=db, parent_user_id=current_user_id, child_id=child_id, payload=payload, ) if result.idempotent: response.status_code = status.HTTP_200_OK logger.info( "parent child message created", extra={ "event": "parent_child_message_create", "request_id": getattr(request.state, "request_id", None), "user_id": current_user_id, "child_id": child_id, "conversation_id": result.conversation_id, "idempotent": result.idempotent, }, ) return ConversationMessageCreateResponse( idempotent=result.idempotent, conversation_id=result.conversation_id, conversation_type=result.conversation_type, conversation_type_name=result.conversation_type_name, message=result.message, ) @router.get( "/{child_id}/conversations/{conversation_id}/messages", response_model=ChildConversationMessageListResponse, ) def list_child_conversation_messages( child_id: int, conversation_id: int, request: Request, cursor_seq: int | None = Query(default=None, ge=1), limit: int = Query(default=20, ge=1, le=100), current_user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db), ) -> ChildConversationMessageListResponse: _assert_child_access(db=db, child_id=child_id, user_id=current_user_id) _get_conversation_for_child(db=db, conversation_id=conversation_id, child_id=child_id) sql = """ SELECT id, conversation_id, seq, sender_type, sender_id, receiver_type, receiver_id, content_type, content_text, content_json, media_file_key, media_duration_ms, media_mime_type, media_size_bytes, media_transcript_text, client_msg_id, sender_name_snapshot, sender_avatar_snapshot, receiver_name_snapshot, receiver_avatar_snapshot, ext_json, created_at FROM im_messages WHERE conversation_id = :conversation_id AND deleted_at IS NULL """ params: dict[str, Any] = { "conversation_id": conversation_id, "fetch_limit": limit + 1, } if cursor_seq is not None: sql += " AND seq < :cursor_seq" params["cursor_seq"] = cursor_seq sql += " ORDER BY seq DESC LIMIT :fetch_limit" rows = db.execute(text(sql), params).mappings().all() has_more = len(rows) > limit rows = rows[:limit] rows.reverse() items = [_row_to_message_item(row) for row in rows] next_cursor_seq = items[0].seq if has_more and items else None logger.info( "child conversation messages listed", extra={ "event": "child_conversation_message_list", "request_id": getattr(request.state, "request_id", None), "user_id": current_user_id, "child_id": child_id, "conversation_id": conversation_id, "count": len(items), "has_more": has_more, }, ) return ChildConversationMessageListResponse( conversation_id=conversation_id, has_more=has_more, next_cursor_seq=next_cursor_seq, items=items, )