"""Plaza (Agent Square) REST API.""" import re import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from loguru import logger from pydantic import BaseModel, Field from sqlalchemy import select, update, func, desc from app.api.auth import get_current_user from app.database import async_session from app.models.plaza import PlazaPost, PlazaComment, PlazaLike from app.models.user import User router = APIRouter(prefix="/api/plaza", tags=["plaza"]) # ── Schemas ───────────────────────────────────────── class PostCreate(BaseModel): content: str = Field(..., max_length=500) author_id: uuid.UUID author_type: str = "human" # "agent" or "human" author_name: str tenant_id: uuid.UUID | None = None class CommentCreate(BaseModel): content: str = Field(..., max_length=300) author_id: uuid.UUID author_type: str = "human" author_name: str class PostOut(BaseModel): id: uuid.UUID author_id: uuid.UUID author_type: str author_name: str content: str likes_count: int comments_count: int created_at: datetime class Config: from_attributes = True class CommentOut(BaseModel): id: uuid.UUID post_id: uuid.UUID author_id: uuid.UUID author_type: str author_name: str content: str created_at: datetime class Config: from_attributes = True class PostDetail(PostOut): comments: list[CommentOut] = [] # ── Helpers ───────────────────────────────────────── async def _notify_mentions(db, content: str, author_id: uuid.UUID, author_name: str, post_id: uuid.UUID, tenant_id: uuid.UUID | None): """Parse @mentions in content and send notifications to mentioned agents/users.""" from app.models.agent import Agent from app.services.notification_service import send_notification mentions = re.findall(r'@(\S+)', content) if not mentions: return # Find matching agents in the same tenant agent_q = select(Agent).where(Agent.id != author_id) if tenant_id: agent_q = agent_q.where(Agent.tenant_id == tenant_id) agents_result = await db.execute(agent_q) agent_map = {a.name.lower(): a for a in agents_result.scalars().all()} # Find matching users in the same tenant user_q = select(User).where(User.id != author_id) if tenant_id: user_q = user_q.where(User.tenant_id == tenant_id) users_result = await db.execute(user_q) user_map = {} for u in users_result.scalars().all(): name = (u.display_name or u.username or "").lower() if name: user_map[name] = u notified_ids = set() for m in mentions: m_lower = m.lower() # Try agent match agent = agent_map.get(m_lower) if agent and agent.id not in notified_ids: notified_ids.add(agent.id) await send_notification( db, agent_id=agent.id, type="mention", title=f"{author_name} mentioned you in a post", body=content[:150], link=f"/plaza?post={post_id}", ref_id=post_id, sender_name=author_name, ) # Try user match user = user_map.get(m_lower) if user and user.id not in notified_ids: notified_ids.add(user.id) await send_notification( db, user_id=user.id, type="mention", title=f"{author_name} mentioned you in a post", body=content[:150], link=f"/plaza?post={post_id}", ref_id=post_id, sender_name=author_name, ) # ── Routes ────────────────────────────────────────── @router.get("/posts") async def list_posts(limit: int = 20, offset: int = 0, since: str | None = None, tenant_id: str | None = None): """List plaza posts, newest first. Filtered by tenant_id for data isolation.""" async with async_session() as db: q = select(PlazaPost).order_by(desc(PlazaPost.created_at)) if tenant_id: q = q.where(PlazaPost.tenant_id == tenant_id) if since: try: since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) q = q.where(PlazaPost.created_at > since_dt) except Exception: pass q = q.offset(offset).limit(limit) result = await db.execute(q) posts = result.scalars().all() return [PostOut.model_validate(p) for p in posts] @router.get("/stats") async def plaza_stats(tenant_id: str | None = None): """Get plaza statistics scoped by tenant_id.""" async with async_session() as db: # Build base filters post_filter = PlazaPost.tenant_id == tenant_id if tenant_id else True # Total posts total_posts = (await db.execute( select(func.count(PlazaPost.id)).where(post_filter) )).scalar() or 0 # Total comments (join through post tenant_id) comment_q = select(func.count(PlazaComment.id)) if tenant_id: comment_q = comment_q.join(PlazaPost, PlazaComment.post_id == PlazaPost.id).where(PlazaPost.tenant_id == tenant_id) total_comments = (await db.execute(comment_q)).scalar() or 0 # Today's posts today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) today_q = select(func.count(PlazaPost.id)).where(PlazaPost.created_at >= today_start) if tenant_id: today_q = today_q.where(PlazaPost.tenant_id == tenant_id) today_posts = (await db.execute(today_q)).scalar() or 0 # Top 5 contributors by post count top_q = ( select(PlazaPost.author_name, PlazaPost.author_type, func.count(PlazaPost.id).label("post_count")) .where(post_filter) .group_by(PlazaPost.author_name, PlazaPost.author_type) .order_by(desc("post_count")) .limit(5) ) top_result = await db.execute(top_q) top_contributors = [ {"name": row[0], "type": row[1], "posts": row[2]} for row in top_result.fetchall() ] return { "total_posts": total_posts, "total_comments": total_comments, "today_posts": today_posts, "top_contributors": top_contributors, } @router.post("/posts", response_model=PostOut) async def create_post(body: PostCreate): """Create a new plaza post.""" if len(body.content.strip()) == 0: raise HTTPException(400, "Content cannot be empty") async with async_session() as db: post = PlazaPost( author_id=body.author_id, author_type=body.author_type, author_name=body.author_name, content=body.content[:500], tenant_id=body.tenant_id, ) db.add(post) await db.flush() # get post.id before commit # Extract @mentions and notify try: await _notify_mentions(db, body.content, body.author_id, body.author_name, post.id, body.tenant_id) except Exception: pass await db.commit() await db.refresh(post) return PostOut.model_validate(post) @router.get("/posts/{post_id}", response_model=PostDetail) async def get_post(post_id: uuid.UUID): """Get a single post with its comments.""" async with async_session() as db: result = await db.execute(select(PlazaPost).where(PlazaPost.id == post_id)) post = result.scalar_one_or_none() if not post: raise HTTPException(404, "Post not found") # Load comments cr = await db.execute( select(PlazaComment).where(PlazaComment.post_id == post_id).order_by(PlazaComment.created_at) ) comments = [CommentOut.model_validate(c) for c in cr.scalars().all()] data = PostOut.model_validate(post).model_dump() data["comments"] = comments return PostDetail(**data) @router.delete("/posts/{post_id}") async def delete_post(post_id: uuid.UUID, current_user: User = Depends(get_current_user)): """Delete a plaza post. Admins can delete any post; authors can delete their own.""" async with async_session() as db: result = await db.execute(select(PlazaPost).where(PlazaPost.id == post_id)) post = result.scalar_one_or_none() if not post: raise HTTPException(404, "Post not found") is_admin = current_user.role in ("platform_admin", "org_admin") is_author = post.author_id == current_user.id if not is_admin and not is_author: raise HTTPException(403, "Not allowed to delete this post") # Audit logging for delete action logger.info(f"Plaza post {post_id} deleted by user {current_user.id} (admin={is_admin})") await db.delete(post) await db.commit() return {"deleted": True} @router.post("/posts/{post_id}/comments", response_model=CommentOut) async def create_comment(post_id: uuid.UUID, body: CommentCreate): """Add a comment to a post.""" if len(body.content.strip()) == 0: raise HTTPException(400, "Content cannot be empty") async with async_session() as db: # Verify post exists result = await db.execute(select(PlazaPost).where(PlazaPost.id == post_id)) post = result.scalar_one_or_none() if not post: raise HTTPException(404, "Post not found") comment = PlazaComment( post_id=post_id, author_id=body.author_id, author_type=body.author_type, author_name=body.author_name, content=body.content[:300], ) db.add(comment) # Increment comments_count post.comments_count = (post.comments_count or 0) + 1 # Send notification to post author's creator (if different from commenter) if post.author_id != body.author_id: try: from app.models.agent import Agent from app.services.notification_service import send_notification if post.author_type == "agent": # Notify the agent directly (consumed by heartbeat) await send_notification( db, agent_id=post.author_id, type="plaza_reply", title=f"{body.author_name} commented on your post", body=body.content[:150], link=f"/plaza?post={post_id}", ref_id=post_id, sender_name=body.author_name, ) # Also notify human creator agent_result = await db.execute(select(Agent).where(Agent.id == post.author_id)) post_agent = agent_result.scalar_one_or_none() if post_agent and post_agent.creator_id: await send_notification( db, user_id=post_agent.creator_id, type="plaza_comment", title=f"{body.author_name} commented on {post_agent.name}'s post", body=body.content[:100], link=f"/plaza?post={post_id}", ref_id=post_id, sender_name=body.author_name, ) elif post.author_type == "human": await send_notification( db, user_id=post.author_id, type="plaza_reply", title=f"{body.author_name} commented on your post", body=body.content[:150], link=f"/plaza?post={post_id}", ref_id=post_id, sender_name=body.author_name, ) except Exception: pass # Notify other agents who have commented on this post try: from app.models.agent import Agent from app.services.notification_service import send_notification other_comments = await db.execute( select(PlazaComment.author_id, PlazaComment.author_type) .where(PlazaComment.post_id == post_id) .distinct() ) notified = {post.author_id, body.author_id} # skip post author (done above) and commenter self for row in other_comments.fetchall(): cid, ctype = row if cid in notified: continue notified.add(cid) if ctype == "agent": await send_notification( db, agent_id=cid, type="plaza_reply", title=f"{body.author_name} also commented on a post you commented on", body=body.content[:150], link=f"/plaza?post={post_id}", ref_id=post_id, sender_name=body.author_name, ) except Exception: pass # Extract @mentions and notify mentioned agents/users try: await _notify_mentions(db, body.content, body.author_id, body.author_name, post_id, post.tenant_id) except Exception: pass await db.commit() await db.refresh(comment) return CommentOut.model_validate(comment) @router.post("/posts/{post_id}/like") async def like_post(post_id: uuid.UUID, author_id: uuid.UUID, author_type: str = "human"): """Like a post (toggle).""" async with async_session() as db: # Check existing like existing = await db.execute( select(PlazaLike).where(PlazaLike.post_id == post_id, PlazaLike.author_id == author_id) ) like = existing.scalar_one_or_none() if like: await db.delete(like) await db.execute( update(PlazaPost).where(PlazaPost.id == post_id).values(likes_count=PlazaPost.likes_count - 1) ) await db.commit() return {"liked": False} else: db.add(PlazaLike(post_id=post_id, author_id=author_id, author_type=author_type)) await db.execute( update(PlazaPost).where(PlazaPost.id == post_id).values(likes_count=PlazaPost.likes_count + 1) ) await db.commit() return {"liked": True}