from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import desc, select from sqlalchemy.orm import Session from app.db.base import get_db from app.models import Group, Member, Message, Thread from app.schemas import MessageCreate, ThreadCreate from app.services.auth import CurrentContext, audit, get_current_context, get_member_for_group, get_members_for_context from app.services.serializers import group_dict, iso, member_dict router = APIRouter(prefix="/api", tags=["chat"]) LOW_SIGNAL_PHRASES = {"ok", "okay", "yes", "no", "thanks", "thank you", "great", "👍", "+1", "fine", "done"} def low_signal(body: str) -> bool: normalized = body.strip().lower() return len(normalized) <= 24 and (normalized in LOW_SIGNAL_PHRASES or normalized.replace("!", "") in LOW_SIGNAL_PHRASES) def message_chat_dict(message: Message, members_by_id: dict[str, Member], current_member_id: str | None) -> dict: author = members_by_id.get(message.author_member_id) return { "id": message.id, "thread_id": message.thread_id, "author_member_id": message.author_member_id, "author_name": author.display_name if author else "Member", "body": message.body, "created_at": iso(message.created_at), "mine": message.author_member_id == current_member_id, "low_signal": low_signal(message.body), } def thread_chat_dict(db: Session, thread: Thread, group: Group, current_member_id: str | None, include_messages: bool = True) -> dict: members = db.scalars(select(Member).where(Member.group_id == group.id)).all() members_by_id = {member.id: member for member in members} messages = [] latest_message = None if include_messages: rows = list(db.scalars(select(Message).where(Message.thread_id == thread.id).order_by(Message.created_at)).all()) messages = [message_chat_dict(message, members_by_id, current_member_id) for message in rows] latest_message = messages[-1] if messages else None else: row = db.scalar(select(Message).where(Message.thread_id == thread.id).order_by(desc(Message.created_at)).limit(1)) latest_message = message_chat_dict(row, members_by_id, current_member_id) if row else None return { "id": thread.id, "group_id": group.id, "group_name": group.name, "title": thread.title, "kind": thread.kind, "created_at": iso(thread.created_at), "updated_at": iso(thread.updated_at), "latest_message": latest_message, "messages": messages, } @router.get("/chat") def chat_home( group_id: str | None = None, thread_id: str | None = None, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db), ) -> dict: memberships = get_members_for_context(db, ctx) if not memberships: return {"groups": [], "threads": [], "active_group": None, "active_thread": None, "current_member_id": None} groups = [db.get(Group, member.group_id) for member in memberships] groups = [group for group in groups if group is not None and not group.archived_at] active_group = next((group for group in groups if group.id == group_id), groups[0]) active_member = get_member_for_group(db, ctx, active_group.id) threads = list(db.scalars(select(Thread).where(Thread.group_id == active_group.id).order_by(desc(Thread.updated_at))).all()) if not threads: thread = Thread(group_id=active_group.id, title="General", kind="discussion", created_by_member_id=active_member.id) db.add(thread) db.flush() db.commit() threads = [thread] active_thread = next((thread for thread in threads if thread.id == thread_id), threads[0]) members = list(db.scalars(select(Member).where(Member.group_id == active_group.id, Member.status.in_(["joined", "verified"]))).all()) return { "groups": [ {"group": group_dict(group), "member": member_dict(next(member for member in memberships if member.group_id == group.id))} for group in groups ], "active_group": group_dict(active_group), "active_member": member_dict(active_member) if active_member else None, "members": [member_dict(member) for member in members], "threads": [thread_chat_dict(db, thread, active_group, active_member.id if active_member else None, include_messages=False) for thread in threads], "active_thread": thread_chat_dict(db, active_thread, active_group, active_member.id if active_member else None, include_messages=True), "current_member_id": active_member.id if active_member else None, } @router.post("/chat/threads") def create_chat_thread(payload: ThreadCreate, group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: group = db.get(Group, group_id) if not group: raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Group not found.", "details": {}}}) member = get_member_for_group(db, ctx, group.id) if not member: raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "Join this group to continue.", "details": {}}}) thread = Thread(group_id=group.id, title=payload.title, kind=payload.kind, created_by_member_id=member.id) db.add(thread) audit(db, ctx=ctx, action="chat_thread_created", resource_type="thread", resource_id=thread.id) db.commit() return {"thread": thread_chat_dict(db, thread, group, member.id)} @router.post("/chat/threads/{thread_id}/messages") def create_chat_message(thread_id: str, payload: MessageCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: thread = db.get(Thread, thread_id) if not thread: raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Thread not found.", "details": {}}}) group = db.get(Group, thread.group_id) member = get_member_for_group(db, ctx, group.id) if not member: raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "Join this group to continue.", "details": {}}}) if thread.kind == "archive": raise HTTPException(status_code=403, detail={"error": {"code": "archive_read_only", "message": "Imported archive threads are read-only.", "details": {}}}) from app.core.security import utc_now now = utc_now() message = Message(thread_id=thread.id, author_member_id=member.id, body=payload.body, created_at=now) thread.updated_at = now db.add(message) audit(db, ctx=ctx, action="chat_message_created", resource_type="thread", resource_id=thread.id) db.commit() members = {item.id: item for item in db.scalars(select(Member).where(Member.group_id == group.id)).all()} return {"message": message_chat_dict(message, members, member.id)}