Spaces:
Running
Running
| """ | |
| 👥 COLLABORATION HUB — Team Chat, Channels, Invites, AI Insights | |
| =================================================================== | |
| Fully functional collaboration workspace with channels, messaging, | |
| invite link generation, team management, and @ai data insights. | |
| Migrated to PostgreSQL DB Models. | |
| """ | |
| from fastapi import APIRouter, Header, HTTPException, Depends, Request, Query | |
| from pydantic import BaseModel | |
| from typing import List, Optional, Dict | |
| from datetime import datetime | |
| import hashlib | |
| import json | |
| import logging | |
| from core.rate_limiter import check_rate_limit | |
| from core.agent_swarm import CollaborationSwarm | |
| collab_swarm = CollaborationSwarm() | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from sqlalchemy.future import select | |
| from sqlalchemy.orm import selectinload | |
| from sqlalchemy import delete | |
| from database.db import get_db | |
| from database.orm import ChatChannel, ChannelMessage, MessageReaction, ActivityLog, WorkspaceMember, UserProfile, Workspace | |
| from api.deps import get_current_user_id | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter() | |
| # ── Pydantic Models ── | |
| class PostMessageRequest(BaseModel): | |
| message: str | |
| user: str = "Naveenkumar" | |
| channel_id: Optional[str] = "default" | |
| is_encrypted: bool = False | |
| attachment_url: Optional[str] = None | |
| attachment_type: Optional[str] = None | |
| class CreateChannelRequest(BaseModel): | |
| name: str | |
| class InviteMemberRequest(BaseModel): | |
| name: str | |
| email: str | |
| role: str = "viewer" | |
| class RemoveMemberRequest(BaseModel): | |
| email: str | |
| class UpdateRoleRequest(BaseModel): | |
| email: str | |
| role: str | |
| class ReactionRequest(BaseModel): | |
| emoji: str | |
| user: str = "Naveenkumar" | |
| class ReplyRequest(BaseModel): | |
| message: str | |
| user: str = "Naveenkumar" | |
| class PinRequest(BaseModel): | |
| message_id: str | |
| async def get_user_workspaces( | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Get all workspaces the current user is a member of.""" | |
| # 1. Get their personal workspace | |
| workspaces = [] | |
| stmt = select(UserProfile).where(UserProfile.id == user_id) | |
| user_res = await db.execute(stmt) | |
| user = user_res.scalars().first() | |
| personal_name = "Personal Workspace" | |
| if user and user.full_name: | |
| personal_name = f"{user.full_name}'s Workspace" | |
| elif user and user.email: | |
| personal_name = f"{user.email.split('@')[0]}'s Workspace" | |
| workspaces.append({ | |
| "id": user_id, # personal workspace ID is the user ID | |
| "name": personal_name, | |
| "role": "Owner" | |
| }) | |
| # 2. Get other workspaces they are a member of | |
| from sqlalchemy import cast, String | |
| stmt = select(WorkspaceMember, UserProfile).join( | |
| UserProfile, WorkspaceMember.workspace_id == cast(UserProfile.id, String) | |
| ).where(WorkspaceMember.user_id == user_id) | |
| res = await db.execute(stmt) | |
| memberships = res.all() | |
| for member, owner in memberships: | |
| ws_name = f"{owner.full_name or owner.email.split('@')[0]}'s Workspace" | |
| workspaces.append({ | |
| "id": member.workspace_id, | |
| "name": ws_name, | |
| "role": member.role | |
| }) | |
| return {"workspaces": workspaces} | |
| # ── DB Helpers ── | |
| async def _get_or_create_workspace_channel(db: AsyncSession, workspace_id: str) -> ChatChannel: | |
| name = f"general_{workspace_id}" | |
| stmt = select(ChatChannel).where(ChatChannel.name == name) | |
| result = await db.execute(stmt) | |
| channel = result.scalar_one_or_none() | |
| if not channel: | |
| channel = ChatChannel(name=name, description="General discussion") | |
| db.add(channel) | |
| await db.commit() | |
| await db.refresh(channel) | |
| return channel | |
| async def _resolve_channel_id(channel_id: str, db: AsyncSession, workspace_id: str) -> str: | |
| """Resolve 'default' to the UUID of the workspace's general channel.""" | |
| if channel_id == "default": | |
| channel = await _get_or_create_workspace_channel(db, workspace_id) | |
| return channel.id | |
| try: | |
| import uuid as _uuid | |
| return _uuid.UUID(str(channel_id)) | |
| except ValueError: | |
| raise HTTPException(status_code=400, detail="Invalid channel ID") | |
| def _message_uuid(message_id: str): | |
| import uuid as _uuid | |
| try: | |
| return _uuid.UUID(str(message_id)) | |
| except ValueError: | |
| raise HTTPException(status_code=400, detail="Invalid message ID") | |
| def _generate_ai_insight(user_id: str, user_question: str) -> Optional[Dict]: | |
| """ | |
| Generate a real AI insight by analyzing the user's uploaded data. | |
| """ | |
| try: | |
| from api.v1.endpoints.charts import get_user_data | |
| import pandas as pd | |
| import numpy as np | |
| df = get_user_data(user_id) | |
| if df is None or df.empty: | |
| return { | |
| "id": str(int(datetime.now().timestamp() * 1000) + 1), | |
| "user": "DataVision AI", | |
| "avatar": "✨", | |
| "message": "I don't see any uploaded data yet. Please upload a dataset in the Data Hub first, then I can analyze it for you!", | |
| "time": "Just now", | |
| "chartRef": "System", | |
| "isAi": True, | |
| } | |
| source_file = df['_source_file'].iloc[0] if '_source_file' in df.columns else "your data" | |
| numeric_cols = [c for c in df.select_dtypes(include=[np.number]).columns if not c.startswith('_')] | |
| categorical_cols = [c for c in df.select_dtypes(include=['object', 'category']).columns if not c.startswith('_')] | |
| # Build a context-aware response | |
| question_lower = user_question.lower() | |
| insight = "" | |
| if any(word in question_lower for word in ['summary', 'overview', 'describe', 'tell me', 'what']): | |
| # Give a data summary | |
| insight = f"📊 **{source_file}** has {len(df):,} rows × {len(df.columns)} columns.\n\n" | |
| if numeric_cols: | |
| top_num = numeric_cols[0] | |
| insight += f"• **{top_num.replace('_', ' ').title()}**: Mean = {df[top_num].mean():,.2f}, Max = {df[top_num].max():,.2f}\n" | |
| if categorical_cols: | |
| top_cat = categorical_cols[0] | |
| top_value = df[top_cat].value_counts().head(1) | |
| if len(top_value) > 0: | |
| insight += f"• **Top {top_cat.replace('_', ' ').title()}**: '{top_value.index[0]}' ({top_value.values[0]} occurrences)\n" | |
| insight += f"\nI found {len(numeric_cols)} numeric and {len(categorical_cols)} categorical columns ready for analysis." | |
| elif any(word in question_lower for word in ['top', 'best', 'highest', 'max', 'most']): | |
| if numeric_cols and categorical_cols: | |
| num_col = numeric_cols[0] | |
| cat_col = categorical_cols[0] | |
| top_groups = df.groupby(cat_col)[num_col].mean().nlargest(3) | |
| insight = f"🏆 Top 3 by average {num_col.replace('_', ' ')}:\n\n" | |
| for name, val in top_groups.items(): | |
| insight += f"• **{name}**: {val:,.2f}\n" | |
| else: | |
| insight = f"I analyzed your data. The maximum value across numeric columns is {df[numeric_cols[0]].max():,.2f} in the '{numeric_cols[0]}' column." | |
| elif any(word in question_lower for word in ['trend', 'pattern', 'anomaly', 'outlier']): | |
| if numeric_cols: | |
| col = numeric_cols[0] | |
| mean = df[col].mean() | |
| std = df[col].std() | |
| outliers = df[abs(df[col] - mean) > 2 * std] if std > 0 else pd.DataFrame() | |
| insight = f"🔍 Analyzing '{col.replace('_', ' ').title()}': Mean = {mean:,.2f}, Std = {std:,.2f}.\n\n" | |
| insight += f"Found **{len(outliers)} outliers** (>2σ from mean) out of {len(df):,} records." | |
| else: | |
| insight = "I couldn't find numeric columns to analyze for trends." | |
| else: | |
| # Enhanced generic insight | |
| insight = f"Based on my deeper analysis of **{source_file}**, I noticed {len(numeric_cols)} numeric metrics and {len(categorical_cols)} dimensions.\n\n" | |
| if numeric_cols: | |
| insight += f"The primary metric `{numeric_cols[0]}` has a variance of {df[numeric_cols[0]].var():,.2f}. I recommend looking into the correlation between `{numeric_cols[0]}` and `{categorical_cols[0] if categorical_cols else 'time'}` to uncover underlying growth drivers.\n\n" | |
| insight += "If you'd like a specific visualization, just ask me to plot a chart for you!" | |
| return { | |
| "id": str(int(datetime.now().timestamp() * 1000) + 1), | |
| "user": "DataVision AI", | |
| "avatar": "✨", | |
| "message": insight, | |
| "time": "Just now", | |
| "chartRef": f"Analysis of {source_file}", | |
| "isAi": True, | |
| } | |
| except Exception as e: | |
| logger.error(f"AI insight error: {e}") | |
| return { | |
| "id": str(int(datetime.now().timestamp() * 1000) + 1), | |
| "user": "DataVision AI", | |
| "avatar": "✨", | |
| "message": f"I encountered an issue analyzing your data: {str(e)[:100]}. Please try again!", | |
| "time": "Just now", | |
| "chartRef": "Error", | |
| "isAi": True, | |
| } | |
| # ── THREADS ── | |
| async def get_threads( | |
| channel_id: str = "default", | |
| limit: int = Query(50, ge=1, le=200, description="Max messages to return"), | |
| offset: int = Query(0, ge=0, description="Skip this many messages"), | |
| user_id: str = Depends(get_current_user_id), | |
| workspace_id: Optional[str] = Header(None, alias="X-Workspace-ID"), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Fetch messages for a channel with pagination.""" | |
| effective_workspace = workspace_id or user_id | |
| real_channel_id = await _resolve_channel_id(channel_id, db, effective_workspace) | |
| # Count total messages for pagination metadata | |
| from sqlalchemy import func | |
| count_stmt = select(func.count()).select_from(ChannelMessage).where( | |
| ChannelMessage.channel_id == real_channel_id, | |
| ChannelMessage.parent_id == None | |
| ) | |
| total_result = await db.execute(count_stmt) | |
| total_count = total_result.scalar() or 0 | |
| # Load messages with limit/offset | |
| stmt = select(ChannelMessage).where( | |
| ChannelMessage.channel_id == real_channel_id, | |
| ChannelMessage.parent_id == None | |
| ).order_by(ChannelMessage.created_at.asc()).offset(offset).limit(limit).options( | |
| selectinload(ChannelMessage.user) | |
| ) | |
| result = await db.execute(stmt) | |
| messages = result.scalars().all() | |
| # Format for frontend | |
| formatted_threads = [] | |
| for m in messages: | |
| # Try parse JSON content | |
| is_enc = False | |
| att_url = None | |
| att_type = None | |
| msg_text = m.content | |
| try: | |
| parsed = json.loads(m.content) | |
| if isinstance(parsed, dict) and "message" in parsed: | |
| msg_text = parsed.get("message", "") | |
| is_enc = parsed.get("is_encrypted", False) | |
| att_url = parsed.get("attachment_url") | |
| att_type = parsed.get("attachment_type") | |
| except Exception: | |
| pass | |
| formatted_threads.append({ | |
| "id": str(m.id), | |
| "user": msg_text.split(":")[0] if ":" in msg_text and m.is_ai == False else ("DataVision AI" if m.is_ai else m.user.full_name if m.user else "User"), | |
| "avatar": "✨" if m.is_ai else (m.user.full_name[0].upper() if m.user and m.user.full_name else "U"), | |
| "message": msg_text, | |
| "time": m.created_at.isoformat(), | |
| "isAi": m.is_ai, | |
| "is_pinned": m.is_pinned, | |
| "is_encrypted": is_enc, | |
| "attachment_url": att_url, | |
| "attachment_type": att_type | |
| }) | |
| return { | |
| "threads": formatted_threads, | |
| "pagination": { | |
| "total": total_count, | |
| "limit": limit, | |
| "offset": offset, | |
| "has_more": (offset + limit) < total_count | |
| } | |
| } | |
| async def post_message( | |
| request_obj: Request, | |
| req: PostMessageRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| workspace_id: Optional[str] = Header(None, alias="X-Workspace-ID"), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Post a message. If @ai is mentioned, generate a real data insight.""" | |
| await check_rate_limit(request_obj, "collab_message", user_id) | |
| effective_workspace = workspace_id or user_id | |
| real_channel_id = await _resolve_channel_id(req.channel_id or "default", db, effective_workspace) | |
| # In DB we store user profile, but frontend sends 'user' string for display name. We'll store it in content for now, or use the DB user profile. | |
| # Wait, the DB model requires `user_id`. | |
| content_payload = req.message | |
| if req.is_encrypted or req.attachment_url: | |
| content_payload = json.dumps({ | |
| "message": req.message, | |
| "is_encrypted": req.is_encrypted, | |
| "attachment_url": req.attachment_url, | |
| "attachment_type": req.attachment_type | |
| }) | |
| import uuid as _uuid | |
| try: | |
| uid = _uuid.UUID(user_id) | |
| except ValueError: | |
| uid = _uuid.uuid5(_uuid.NAMESPACE_OID, str(user_id)) | |
| new_msg = ChannelMessage( | |
| channel_id=real_channel_id, | |
| user_id=uid, | |
| content=content_payload, | |
| is_ai=False | |
| ) | |
| db.add(new_msg) | |
| await db.commit() | |
| await db.refresh(new_msg) | |
| response_msg = { | |
| "id": str(new_msg.id), | |
| "user": req.user, | |
| "avatar": req.user[0].upper() if req.user else "U", | |
| "message": req.message, | |
| "time": "Just now", | |
| "isAi": False, | |
| "is_encrypted": req.is_encrypted, | |
| "attachment_url": req.attachment_url, | |
| "attachment_type": req.attachment_type | |
| } | |
| ai_response = None | |
| if "@ai" in req.message.lower(): | |
| question = req.message.lower().split("@ai")[-1].strip() | |
| if not question: | |
| question = "give me a summary" | |
| ai_insight = await collab_swarm.process_message(user_id, question) | |
| if ai_insight: | |
| try: | |
| ws_uid = _uuid.UUID(effective_workspace) | |
| except ValueError: | |
| ws_uid = _uuid.uuid5(_uuid.NAMESPACE_OID, str(effective_workspace)) | |
| ai_msg = ChannelMessage( | |
| channel_id=real_channel_id, | |
| user_id=ws_uid, # Attribute AI message to workspace owner | |
| content=ai_insight['message'], | |
| is_ai=True | |
| ) | |
| db.add(ai_msg) | |
| await db.commit() | |
| await db.refresh(ai_msg) | |
| ai_response = ai_insight | |
| ai_response['id'] = str(ai_msg.id) | |
| return {"success": True, "message": response_msg, "ai_response": ai_response} | |
| # ── CHANNELS ── | |
| async def get_channels( | |
| user_id: str = Depends(get_current_user_id), | |
| workspace_id: Optional[str] = Header(None, alias="X-Workspace-ID"), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """List all channels for workspace.""" | |
| effective_workspace = workspace_id or user_id | |
| await _get_or_create_workspace_channel(db, effective_workspace) # Ensure default exists | |
| stmt = select(ChatChannel).where(ChatChannel.name.like(f"%_{effective_workspace}")).order_by(ChatChannel.created_at.asc()) | |
| result = await db.execute(stmt) | |
| channels = result.scalars().all() | |
| return {"channels": [{"id": "default" if c.name == f"general_{effective_workspace}" else str(c.id), "name": c.name.replace(f"_{effective_workspace}", ""), "created": c.created_at.isoformat()} for c in channels]} | |
| async def create_channel( | |
| req: CreateChannelRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| workspace_id: Optional[str] = Header(None, alias="X-Workspace-ID"), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Create a new channel for workspace.""" | |
| effective_workspace = workspace_id or user_id | |
| channel_name = f"{req.name.replace('#', '')}_{effective_workspace}" | |
| stmt = select(ChatChannel).where(ChatChannel.name == channel_name) | |
| result = await db.execute(stmt) | |
| if result.scalar_one_or_none(): | |
| raise HTTPException(status_code=400, detail=f"Channel '{req.name}' already exists") | |
| channel = ChatChannel( | |
| name=channel_name, | |
| description="" | |
| ) | |
| db.add(channel) | |
| await db.commit() | |
| await db.refresh(channel) | |
| return {"success": True, "channel": {"id": str(channel.id), "name": req.name.replace('#', ''), "created": channel.created_at.isoformat()}} | |
| async def search_messages( | |
| q: str, | |
| channel_id: str = "default", | |
| user_id: str = Depends(get_current_user_id), | |
| workspace_id: Optional[str] = Header(None, alias="X-Workspace-ID"), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Search messages in a channel.""" | |
| effective_workspace = workspace_id or user_id | |
| real_channel_id = await _resolve_channel_id(channel_id, db, effective_workspace) | |
| # Simple ILIKE search on content | |
| # Note: E2E encrypted messages won't be matched by plaintext queries! | |
| stmt = select(ChannelMessage).where( | |
| ChannelMessage.channel_id == real_channel_id, | |
| ChannelMessage.content.ilike(f"%{q}%") | |
| ).order_by(ChannelMessage.created_at.desc()).limit(20).options(selectinload(ChannelMessage.user)) | |
| result = await db.execute(stmt) | |
| messages = result.scalars().all() | |
| formatted = [] | |
| for m in messages: | |
| is_enc = False | |
| att_url = None | |
| att_type = None | |
| msg_text = m.content | |
| try: | |
| parsed = json.loads(m.content) | |
| if isinstance(parsed, dict) and "message" in parsed: | |
| msg_text = parsed.get("message", "") | |
| is_enc = parsed.get("is_encrypted", False) | |
| att_url = parsed.get("attachment_url") | |
| att_type = parsed.get("attachment_type") | |
| except: | |
| pass | |
| formatted.append({ | |
| "id": str(m.id), | |
| "user": msg_text.split(":")[0] if ":" in msg_text and m.is_ai == False else ("DataVision AI" if m.is_ai else m.user.full_name if m.user else "User"), | |
| "avatar": "✨" if m.is_ai else (m.user.full_name[0].upper() if m.user and m.user.full_name else "U"), | |
| "message": msg_text, | |
| "time": m.created_at.isoformat(), | |
| "isAi": m.is_ai, | |
| "is_pinned": m.is_pinned, | |
| "is_encrypted": is_enc, | |
| "attachment_url": att_url, | |
| "attachment_type": att_type | |
| }) | |
| return {"results": formatted} | |
| # ── INVITES ── | |
| _invites_db: Dict[str, Dict] = {} # token -> invite info | |
| async def generate_invite( | |
| user_id: str = Depends(get_current_user_id) | |
| ): | |
| """Generate a secure invite link token.""" | |
| token = hashlib.sha256(f"{user_id}-{datetime.now().isoformat()}".encode()).hexdigest()[:16] | |
| _invites_db[token] = { | |
| "created_by": user_id, | |
| "created_at": datetime.now().isoformat(), | |
| "used": False, | |
| } | |
| return {"success": True, "token": token, "link": f"/collaborate?invite={token}"} | |
| class AcceptInviteRequest(BaseModel): | |
| token: str | |
| async def accept_invite( | |
| req: AcceptInviteRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Process an invite token and add the user to the workspace.""" | |
| token = req.token | |
| if token not in _invites_db: | |
| raise HTTPException(status_code=404, detail="Invalid or expired invite link.") | |
| invite_info = _invites_db[token] | |
| # Check if user is already a member | |
| stmt = select(WorkspaceMember).where(WorkspaceMember.user_id == user_id) | |
| result = await db.execute(stmt) | |
| existing_member = result.scalar_one_or_none() | |
| if existing_member: | |
| return {"success": True, "message": "You are already a member of this workspace."} | |
| # Add new member | |
| new_member = WorkspaceMember( | |
| workspace_id=invite_info["created_by"], # Use inviter's UUID | |
| user_id=user_id, | |
| role="Viewer" # Default role for invite links | |
| ) | |
| db.add(new_member) | |
| await db.commit() | |
| # Optional: Log activity | |
| await _log_activity_db(db, user_id, "System", "join", "Joined workspace via invite link") | |
| return {"success": True, "message": "Successfully joined the workspace!"} | |
| # ── MEMBERS ── | |
| async def get_members( | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """List team members.""" | |
| # We fetch members from WorkspaceMember and their UserProfile | |
| stmt = select(WorkspaceMember).filter(WorkspaceMember.workspace_id == user_id).options(selectinload(WorkspaceMember.user)) | |
| result = await db.execute(stmt) | |
| members_db = result.scalars().all() | |
| formatted_members = [] | |
| for m in members_db: | |
| name = m.user.full_name if m.user and m.user.full_name else m.user.email.split("@")[0] if m.user else "Unknown" | |
| formatted_members.append({ | |
| "name": name, | |
| "email": m.user.email if m.user else "", | |
| "role": m.role, | |
| "status": "Online", | |
| "avatar": name[0].upper() if name else "?" | |
| }) | |
| # If empty, fallback to the authenticated user's profile | |
| if not formatted_members: | |
| try: | |
| user_stmt = select(UserProfile).filter(UserProfile.id == user_id) | |
| u_res = await db.execute(user_stmt) | |
| user_profile = u_res.scalars().first() | |
| if user_profile: | |
| name = user_profile.full_name or user_profile.email.split("@")[0] | |
| formatted_members.append({ | |
| "name": name, | |
| "email": user_profile.email, | |
| "role": "Owner", | |
| "status": "Online", | |
| "avatar": name[0].upper() if name else "?" | |
| }) | |
| else: | |
| formatted_members.append({ | |
| "name": "Admin", "email": "admin@datavision.app", "role": "Owner", "status": "Online", "avatar": "A" | |
| }) | |
| except: | |
| formatted_members.append({ | |
| "name": "Admin", "email": "admin@datavision.app", "role": "Owner", "status": "Online", "avatar": "A" | |
| }) | |
| return {"members": formatted_members} | |
| async def add_member( | |
| req: InviteMemberRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Add a team member to the workspace.""" | |
| # 1. Ensure user exists | |
| user_stmt = select(UserProfile).filter(UserProfile.email == req.email) | |
| user_res = await db.execute(user_stmt) | |
| target_user = user_res.scalars().first() | |
| if not target_user: | |
| # Create a stub user if doesn't exist | |
| import hashlib | |
| fake_pass = hashlib.sha256("stub".encode()).hexdigest() | |
| target_user = UserProfile( | |
| email=req.email, | |
| full_name=req.name, | |
| hashed_password=fake_pass, | |
| password_hash_algorithm="sha256" | |
| ) | |
| db.add(target_user) | |
| await db.flush() | |
| # 2. Get or create a default workspace for the inviter | |
| import uuid | |
| try: | |
| inviter_uuid = uuid.UUID(str(user_id)) | |
| except ValueError: | |
| inviter_uuid = uuid.uuid5(uuid.NAMESPACE_OID, str(user_id)) | |
| inviter_check = await db.execute(select(UserProfile).filter(UserProfile.id == inviter_uuid)) | |
| if not inviter_check.scalars().first(): | |
| db.add(UserProfile(id=inviter_uuid, email=f"{user_id}@guest.local", password_hash_algorithm="none", full_name="Guest User")) | |
| await db.flush() | |
| workspace_stmt = select(Workspace).filter(Workspace.owner_id == inviter_uuid) | |
| workspace_res = await db.execute(workspace_stmt) | |
| workspace = workspace_res.scalars().first() | |
| if not workspace: | |
| import secrets | |
| ws_slug = f"workspace-{secrets.token_hex(4)}" | |
| workspace = Workspace(owner_id=inviter_uuid, name="Default Workspace", slug=ws_slug) | |
| db.add(workspace) | |
| await db.flush() | |
| workspace_id = workspace.id | |
| # 3. Add WorkspaceMember | |
| member_stmt = select(WorkspaceMember).filter( | |
| WorkspaceMember.workspace_id == workspace_id, | |
| WorkspaceMember.user_id == target_user.id | |
| ) | |
| member_res = await db.execute(member_stmt) | |
| existing_member = member_res.scalars().first() | |
| if existing_member: | |
| raise HTTPException(status_code=400, detail="User is already a member") | |
| new_member = WorkspaceMember(workspace_id=workspace_id, user_id=target_user.id, role=req.role) | |
| db.add(new_member) | |
| await db.commit() | |
| name = target_user.full_name or target_user.email.split("@")[0] | |
| # Send invite email via existing email service | |
| email_sent = False | |
| email_error = None | |
| try: | |
| from services.email_service import send_insight_email | |
| from core.auth import create_access_token | |
| import os | |
| inviter_name = "Your team" | |
| try: | |
| inviter_stmt = select(UserProfile).filter(UserProfile.id == user_id) | |
| inviter_res = await db.execute(inviter_stmt) | |
| inviter = inviter_res.scalars().first() | |
| if inviter: | |
| inviter_name = inviter.full_name or inviter.email.split("@")[0] | |
| except: | |
| pass | |
| # Generate a secure invite token valid for 7 days | |
| from datetime import timedelta | |
| invite_token = create_access_token( | |
| {"email": req.email, "type": "invite"}, | |
| expires_delta=timedelta(days=7) | |
| ) | |
| frontend_url = os.environ.get("FRONTEND_URL", "https://datavision-ai-datavision.hf.space") | |
| invite_link = f"{frontend_url}/accept-invite?token={invite_token}&email={req.email}" | |
| send_res = await send_insight_email( | |
| to_email=req.email, | |
| title="You've been invited to DataVision", | |
| body=f"{inviter_name} invited you to collaborate on DataVision as a {req.role}.\n\nClick the link below to accept the invitation and set up your account:\n{invite_link}\n\nIf you already have an account, you can simply log in.", | |
| ) | |
| if send_res: | |
| email_sent = True | |
| logger.info(f"✅ Invite email sent to {req.email}") | |
| else: | |
| email_error = "Email provider unconfigured or failed" | |
| except Exception as e: | |
| email_error = str(e) | |
| logger.warning(f"❌ Failed to send invite email to {req.email}: {e}") | |
| msg = f"Invitation sent to {req.email}" if email_sent else f"Member added. Copy link: {invite_link}" | |
| return { | |
| "success": True, | |
| "member": {"name": name, "email": target_user.email, "role": new_member.role, "status": "Invited" if email_sent else "Added (Pending)", "avatar": name[0].upper()}, | |
| "message": msg, | |
| "email_sent": email_sent, | |
| "invite_link": invite_link | |
| } | |
| async def remove_member( | |
| req: RemoveMemberRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Remove a team member from the workspace.""" | |
| user_stmt = select(UserProfile).filter(UserProfile.email == req.email) | |
| user_res = await db.execute(user_stmt) | |
| target_user = user_res.scalar_one_or_none() | |
| if not target_user: | |
| return {"success": True, "message": "Member removed."} | |
| mem_stmt = select(WorkspaceMember).filter( | |
| WorkspaceMember.workspace_id == user_id, | |
| WorkspaceMember.user_id == target_user.id | |
| ) | |
| mem_res = await db.execute(mem_stmt) | |
| member = mem_res.scalar_one_or_none() | |
| if member: | |
| await db.delete(member) | |
| await db.commit() | |
| await _log_activity_db(db, user_id, "System", "remove", f"Removed {req.email} from workspace") | |
| return {"success": True, "message": f"Successfully removed {req.email}"} | |
| # ── MEMBERS ── | |
| async def get_members( | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """List team members.""" | |
| # We fetch members from WorkspaceMember and their UserProfile | |
| stmt = select(WorkspaceMember).filter(WorkspaceMember.workspace_id == user_id).options(selectinload(WorkspaceMember.user)) | |
| result = await db.execute(stmt) | |
| members_db = result.scalars().all() | |
| formatted_members = [] | |
| for m in members_db: | |
| name = m.user.full_name if m.user and m.user.full_name else m.user.email.split("@")[0] if m.user else "Unknown" | |
| formatted_members.append({ | |
| "name": name, | |
| "email": m.user.email if m.user else "", | |
| "role": m.role, | |
| "status": "Online", | |
| "avatar": name[0].upper() if name else "?" | |
| }) | |
| # If empty, fallback to the authenticated user's profile | |
| if not formatted_members: | |
| try: | |
| user_stmt = select(UserProfile).filter(UserProfile.id == user_id) | |
| u_res = await db.execute(user_stmt) | |
| user_profile = u_res.scalars().first() | |
| if user_profile: | |
| name = user_profile.full_name or user_profile.email.split("@")[0] | |
| formatted_members.append({ | |
| "name": name, | |
| "email": user_profile.email, | |
| "role": "Owner", | |
| "status": "Online", | |
| "avatar": name[0].upper() if name else "?" | |
| }) | |
| else: | |
| formatted_members.append({ | |
| "name": "Admin", "email": "admin@datavision.app", "role": "Owner", "status": "Online", "avatar": "A" | |
| }) | |
| except: | |
| formatted_members.append({ | |
| "name": "Admin", "email": "admin@datavision.app", "role": "Owner", "status": "Online", "avatar": "A" | |
| }) | |
| return {"members": formatted_members} | |
| async def add_member( | |
| req: InviteMemberRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Add a team member to the workspace.""" | |
| # 1. Ensure user exists | |
| user_stmt = select(UserProfile).filter(UserProfile.email == req.email) | |
| user_res = await db.execute(user_stmt) | |
| target_user = user_res.scalars().first() | |
| if not target_user: | |
| # Create a stub user if doesn't exist | |
| import hashlib | |
| fake_pass = hashlib.sha256("stub".encode()).hexdigest() | |
| target_user = UserProfile( | |
| email=req.email, | |
| full_name=req.name, | |
| hashed_password=fake_pass, | |
| password_hash_algorithm="sha256" | |
| ) | |
| db.add(target_user) | |
| await db.flush() | |
| # 2. Get or create a default workspace for the inviter | |
| import uuid | |
| try: | |
| inviter_uuid = uuid.UUID(str(user_id)) | |
| except ValueError: | |
| inviter_uuid = uuid.uuid5(uuid.NAMESPACE_OID, str(user_id)) | |
| inviter_check = await db.execute(select(UserProfile).filter(UserProfile.id == inviter_uuid)) | |
| if not inviter_check.scalars().first(): | |
| db.add(UserProfile(id=inviter_uuid, email=f"{user_id}@guest.local", password_hash_algorithm="none", full_name="Guest User")) | |
| await db.flush() | |
| workspace_stmt = select(Workspace).filter(Workspace.owner_id == inviter_uuid) | |
| workspace_res = await db.execute(workspace_stmt) | |
| workspace = workspace_res.scalars().first() | |
| if not workspace: | |
| import secrets | |
| ws_slug = f"workspace-{secrets.token_hex(4)}" | |
| workspace = Workspace(owner_id=inviter_uuid, name="Default Workspace", slug=ws_slug) | |
| db.add(workspace) | |
| await db.flush() | |
| workspace_id = workspace.id | |
| # 3. Add WorkspaceMember | |
| member_stmt = select(WorkspaceMember).filter( | |
| WorkspaceMember.workspace_id == workspace_id, | |
| WorkspaceMember.user_id == target_user.id | |
| ) | |
| member_res = await db.execute(member_stmt) | |
| existing_member = member_res.scalars().first() | |
| if existing_member: | |
| raise HTTPException(status_code=400, detail="User is already a member") | |
| new_member = WorkspaceMember(workspace_id=workspace_id, user_id=target_user.id, role=req.role) | |
| db.add(new_member) | |
| await db.commit() | |
| name = target_user.full_name or target_user.email.split("@")[0] | |
| # Send invite email via existing email service | |
| email_sent = False | |
| email_error = None | |
| try: | |
| from services.email_service import send_insight_email | |
| from core.auth import create_access_token | |
| import os | |
| inviter_name = "Your team" | |
| try: | |
| inviter_stmt = select(UserProfile).filter(UserProfile.id == user_id) | |
| inviter_res = await db.execute(inviter_stmt) | |
| inviter = inviter_res.scalars().first() | |
| if inviter: | |
| inviter_name = inviter.full_name or inviter.email.split("@")[0] | |
| except: | |
| pass | |
| # Generate a secure invite token valid for 7 days | |
| from datetime import timedelta | |
| invite_token = create_access_token( | |
| {"email": req.email, "type": "invite"}, | |
| expires_delta=timedelta(days=7) | |
| ) | |
| frontend_url = os.environ.get("FRONTEND_URL", "https://datavision-ai-datavision.hf.space") | |
| invite_link = f"{frontend_url}/accept-invite?token={invite_token}&email={req.email}" | |
| send_res = await send_insight_email( | |
| to_email=req.email, | |
| title="You've been invited to DataVision", | |
| body=f"{inviter_name} invited you to collaborate on DataVision as a {req.role}.\n\nClick the link below to accept the invitation and set up your account:\n{invite_link}\n\nIf you already have an account, you can simply log in.", | |
| ) | |
| if send_res: | |
| email_sent = True | |
| logger.info(f"✅ Invite email sent to {req.email}") | |
| else: | |
| email_error = "Email provider unconfigured or failed" | |
| except Exception as e: | |
| email_error = str(e) | |
| logger.warning(f"❌ Failed to send invite email to {req.email}: {e}") | |
| msg = f"Invitation sent to {req.email}" if email_sent else f"Member added. Copy link: {invite_link}" | |
| return { | |
| "success": True, | |
| "member": {"name": name, "email": target_user.email, "role": new_member.role, "status": "Invited" if email_sent else "Added (Pending)", "avatar": name[0].upper()}, | |
| "message": msg, | |
| "email_sent": email_sent, | |
| "invite_link": invite_link | |
| } | |
| async def remove_member( | |
| req: RemoveMemberRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Remove a team member from the workspace.""" | |
| user_stmt = select(UserProfile).filter(UserProfile.email == req.email) | |
| user_res = await db.execute(user_stmt) | |
| target_user = user_res.scalars().first() | |
| if not target_user: | |
| raise HTTPException(status_code=404, detail="User not found") | |
| # Delete from any workspace this user belongs to (scoped by current user's workspace) | |
| stmt = delete(WorkspaceMember).filter( | |
| WorkspaceMember.workspace_id == user_id, | |
| WorkspaceMember.user_id == target_user.id | |
| ) | |
| await db.execute(stmt) | |
| await db.commit() | |
| return {"success": True, "message": f"Removed {req.email}"} | |
| # ── WEBSOCKET REAL-TIME CHAT ── | |
| from fastapi import WebSocket, WebSocketDisconnect | |
| import asyncio | |
| class ConnectionManager: | |
| def __init__(self): | |
| self.active_connections: Dict[str, List[WebSocket]] = {} | |
| async def connect(self, websocket: WebSocket, room_id: str): | |
| await websocket.accept() | |
| if room_id not in self.active_connections: | |
| self.active_connections[room_id] = [] | |
| self.active_connections[room_id].append(websocket) | |
| def disconnect(self, websocket: WebSocket, room_id: str): | |
| if room_id in self.active_connections and websocket in self.active_connections[room_id]: | |
| self.active_connections[room_id].remove(websocket) | |
| async def broadcast(self, message: str, room_id: str): | |
| if room_id in self.active_connections: | |
| disconnected = [] | |
| for connection in self.active_connections[room_id]: | |
| try: | |
| await connection.send_text(message) | |
| except Exception: | |
| disconnected.append(connection) | |
| for d in disconnected: | |
| self.active_connections[room_id].remove(d) | |
| manager = ConnectionManager() | |
| async def websocket_endpoint( | |
| websocket: WebSocket, | |
| room_id: str, | |
| workspace_id: str = "default", | |
| user_name: str = "Anonymous", | |
| user_id: str = "default" | |
| ): | |
| actual_room_id = f"{workspace_id}_{room_id}" | |
| await manager.connect(websocket, actual_room_id) | |
| try: | |
| from database.db import AsyncSessionLocal | |
| from database.orm import ChannelMessage | |
| import uuid as _ws_uuid | |
| while True: | |
| data = await websocket.receive_text() | |
| try: | |
| payload = json.loads(data) | |
| msg_text = payload.get("message", "").lower() | |
| # Deterministic valid UUID for user_id (not null in DB) | |
| try: | |
| u_id_val = _ws_uuid.UUID(user_id) if user_id != "default" else _ws_uuid.uuid5(_ws_uuid.NAMESPACE_OID, "default_user") | |
| except ValueError: | |
| u_id_val = _ws_uuid.uuid5(_ws_uuid.NAMESPACE_OID, str(user_id)) | |
| # Save user message to PostgreSQL database | |
| if "message" in payload and payload.get("user") != "DataVision Agent": | |
| try: | |
| async with AsyncSessionLocal() as db: | |
| effective_workspace = workspace_id if workspace_id != "default" else user_id | |
| real_channel_id = await _resolve_channel_id(room_id, db, effective_workspace) | |
| new_msg = ChannelMessage( | |
| channel_id=real_channel_id, | |
| user_id=u_id_val, | |
| content=payload["message"], | |
| is_ai=False | |
| ) | |
| db.add(new_msg) | |
| await db.commit() | |
| await db.refresh(new_msg) | |
| payload["id"] = str(new_msg.id) | |
| data = json.dumps(payload) | |
| except Exception as db_err: | |
| logger.error(f"Error persisting WS message to DB: {db_err}") | |
| # Broadcast to other peers in room | |
| await manager.broadcast(data, actual_room_id) | |
| # Handle AI @ai questions | |
| is_question = msg_text.strip().endswith("?") | |
| is_mention = "@ai" in msg_text | |
| if (is_mention or is_question) and payload.get("user") != "DataVision Agent": | |
| question = msg_text.replace("@ai", "").strip() | |
| if not question: | |
| question = "give me a summary" | |
| async def _handle_ai(q_text, eff_id, r_id, a_r_id, acting_uid): | |
| try: | |
| ai_response = await collab_swarm.process_message(eff_id, q_text) | |
| if ai_response: | |
| async with AsyncSessionLocal() as session: | |
| real_ch_id = await _resolve_channel_id(r_id, session, eff_id) | |
| ai_msg = ChannelMessage( | |
| channel_id=real_ch_id, | |
| user_id=acting_uid, | |
| content=ai_response.get("message", ""), | |
| is_ai=True | |
| ) | |
| session.add(ai_msg) | |
| await session.commit() | |
| await session.refresh(ai_msg) | |
| ai_response["id"] = str(ai_msg.id) | |
| await asyncio.sleep(0.3) | |
| await manager.broadcast(json.dumps(ai_response), a_r_id) | |
| except Exception as e: | |
| logger.error(f"AI response error in WS: {e}") | |
| effective_id = workspace_id if workspace_id != "default" else user_id | |
| asyncio.create_task(_handle_ai(question, effective_id, room_id, actual_room_id, u_id_val)) | |
| except json.JSONDecodeError: | |
| pass | |
| except Exception as loop_e: | |
| logger.error(f"WS loop exception: {loop_e}") | |
| except WebSocketDisconnect: | |
| manager.disconnect(websocket, actual_room_id) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # ENTERPRISE COLLABORATION FEATURES | |
| # ═══════════════════════════════════════════════════════════════ | |
| VALID_ROLES = {"Owner", "Admin", "Analyst", "Viewer"} | |
| ROLE_PERMISSIONS = { | |
| "Owner": ["view", "edit", "train", "export", "admin", "invite", "delete"], | |
| "Admin": ["view", "edit", "train", "export", "invite"], | |
| "Analyst": ["view", "edit", "train", "export"], | |
| "Viewer": ["view"], | |
| } | |
| async def _log_activity_db(db: AsyncSession, user_id: str, user_name: str, action: str, detail: str): | |
| log = ActivityLog( | |
| user_id=user_id, | |
| user_name=user_name, | |
| action=action, | |
| detail=detail | |
| ) | |
| db.add(log) | |
| await db.commit() | |
| async def get_roles(): | |
| """Get available roles and their permissions.""" | |
| return { | |
| "roles": [ | |
| {"name": role, "permissions": perms, "color": color} | |
| for role, perms, color in [ | |
| ("Owner", ROLE_PERMISSIONS["Owner"], "#EF4444"), | |
| ("Admin", ROLE_PERMISSIONS["Admin"], "#F59E0B"), | |
| ("Analyst", ROLE_PERMISSIONS["Analyst"], "#3B82F6"), | |
| ("Viewer", ROLE_PERMISSIONS["Viewer"], "#6B7280"), | |
| ] | |
| ] | |
| } | |
| async def react_to_message( | |
| message_id: str, | |
| req: ReactionRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Add/toggle emoji reaction on a message.""" | |
| try: | |
| stmt = select(MessageReaction).where( | |
| MessageReaction.message_id == _message_uuid(message_id), | |
| MessageReaction.user_id == user_id, | |
| MessageReaction.emoji == req.emoji | |
| ) | |
| result = await db.execute(stmt) | |
| existing = result.scalar_one_or_none() | |
| if existing: | |
| await db.delete(existing) | |
| await db.commit() | |
| else: | |
| new_reaction = MessageReaction( | |
| message_id=_message_uuid(message_id), | |
| user_id=user_id, | |
| emoji=req.emoji | |
| ) | |
| db.add(new_reaction) | |
| await db.commit() | |
| # Refetch all reactions for message with user names | |
| stmt2 = select(MessageReaction).where(MessageReaction.message_id == _message_uuid(message_id)).options(selectinload(MessageReaction.user)) | |
| result2 = await db.execute(stmt2) | |
| reactions = result2.scalars().all() | |
| grouped = {} | |
| for r in reactions: | |
| if r.emoji not in grouped: | |
| grouped[r.emoji] = [] | |
| # Use actual DB user name instead of always using request user | |
| name = r.user.full_name if r.user and r.user.full_name else (r.user.email.split("@")[0] if r.user and r.user.email else "Unknown") | |
| grouped[r.emoji].append(name) | |
| return {"success": True, "reactions": grouped} | |
| except Exception as e: | |
| logger.error(f"Error reacting: {e}") | |
| return {"success": False, "reactions": {}} | |
| async def get_reactions(message_id: str, db: AsyncSession = Depends(get_db)): | |
| """Get reactions for a message.""" | |
| try: | |
| stmt = select(MessageReaction).where(MessageReaction.message_id == _message_uuid(message_id)).options(selectinload(MessageReaction.user)) | |
| result = await db.execute(stmt) | |
| reactions = result.scalars().all() | |
| grouped = {} | |
| for r in reactions: | |
| if r.emoji not in grouped: | |
| grouped[r.emoji] = [] | |
| name = r.user.full_name if r.user and r.user.full_name else r.user.email.split("@")[0] if r.user else "Unknown" | |
| grouped[r.emoji].append(name) | |
| return {"reactions": grouped} | |
| except Exception: | |
| return {"reactions": {}} | |
| async def reply_to_message( | |
| message_id: str, | |
| req: ReplyRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Add a threaded reply to a message.""" | |
| # Find parent to get channel_id | |
| message_uuid = _message_uuid(message_id) | |
| stmt = select(ChannelMessage).where(ChannelMessage.id == message_uuid) | |
| result = await db.execute(stmt) | |
| parent = result.scalar_one_or_none() | |
| if not parent: | |
| raise HTTPException(status_code=404, detail="Parent message not found") | |
| reply_msg = ChannelMessage( | |
| channel_id=parent.channel_id, | |
| user_id=user_id, | |
| content=req.message, | |
| is_ai=False, | |
| parent_id=message_uuid | |
| ) | |
| db.add(reply_msg) | |
| await db.commit() | |
| await db.refresh(reply_msg) | |
| await _log_activity_db(db, user_id, req.user, "reply", f"Replied in thread: \"{req.message[:50]}\"") | |
| reply = { | |
| "id": str(reply_msg.id), | |
| "parent_id": message_id, | |
| "user": req.user, | |
| "avatar": req.user[0].upper() if req.user else "U", | |
| "message": req.message, | |
| "time": reply_msg.created_at.isoformat(), | |
| "timestamp": reply_msg.created_at.isoformat(), | |
| } | |
| # Count total replies | |
| stmt_count = select(ChannelMessage).where(ChannelMessage.parent_id == message_uuid) | |
| total_replies = len((await db.execute(stmt_count)).scalars().all()) | |
| return {"success": True, "reply": reply, "total_replies": total_replies} | |
| async def get_replies(message_id: str, db: AsyncSession = Depends(get_db)): | |
| """Get all replies for a message thread.""" | |
| stmt = select(ChannelMessage).where(ChannelMessage.parent_id == _message_uuid(message_id)).order_by(ChannelMessage.created_at.asc()).options(selectinload(ChannelMessage.user)) | |
| result = await db.execute(stmt) | |
| replies_db = result.scalars().all() | |
| formatted = [] | |
| for r in replies_db: | |
| name = r.user.full_name if r.user and r.user.full_name else r.user.email.split("@")[0] if r.user else "Unknown" | |
| formatted.append({ | |
| "id": str(r.id), | |
| "parent_id": str(r.parent_id), | |
| "user": name, | |
| "avatar": name[0].upper() if name else "?", | |
| "message": r.content, | |
| "time": r.created_at.isoformat(), | |
| "timestamp": r.created_at.isoformat(), | |
| }) | |
| return {"replies": formatted, "total": len(formatted)} | |
| async def delete_message( | |
| message_id: str, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Explicitly remove only the caller's message and its thread data.""" | |
| import uuid as _uuid | |
| try: | |
| caller_id = _uuid.UUID(user_id) | |
| except ValueError: | |
| caller_id = _uuid.uuid5(_uuid.NAMESPACE_OID, str(user_id)) | |
| message_uuid = _message_uuid(message_id) | |
| message = (await db.execute(select(ChannelMessage).where(ChannelMessage.id == message_uuid))).scalar_one_or_none() | |
| if not message: | |
| raise HTTPException(status_code=404, detail="Message not found") | |
| if message.user_id != caller_id: | |
| raise HTTPException(status_code=403, detail="You can delete only your own messages") | |
| await db.execute(delete(MessageReaction).where(MessageReaction.message_id == message_uuid)) | |
| await db.execute(delete(ChannelMessage).where(ChannelMessage.parent_id == message_uuid)) | |
| await db.delete(message) | |
| await db.commit() | |
| return {"success": True, "message_id": message_id} | |
| async def pin_message( | |
| channel_id: str, | |
| req: PinRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Pin/unpin a message in a channel.""" | |
| try: | |
| stmt = select(ChannelMessage).where(ChannelMessage.id == req.message_id) | |
| result = await db.execute(stmt) | |
| msg = result.scalar_one_or_none() | |
| if not msg: | |
| raise HTTPException(status_code=404, detail="Message not found") | |
| msg.is_pinned = not msg.is_pinned | |
| await db.commit() | |
| if msg.is_pinned: | |
| await _log_activity_db(db, user_id, "System", "pin", "Message pinned in channel") | |
| return {"success": True, "pinned": msg.is_pinned} | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_pins( | |
| channel_id: str, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Get pinned message IDs for a channel.""" | |
| real_channel_id = await _resolve_channel_id(channel_id, db) | |
| stmt = select(ChannelMessage).where( | |
| ChannelMessage.channel_id == real_channel_id, | |
| ChannelMessage.is_pinned == True | |
| ) | |
| result = await db.execute(stmt) | |
| pins = result.scalars().all() | |
| return {"pins": [str(p.id) for p in pins]} | |
| async def get_activity_feed( | |
| limit: int = 50, | |
| user_id: str = Depends(get_current_user_id), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Get platform-wide activity feed.""" | |
| stmt = select(ActivityLog).order_by(ActivityLog.timestamp.desc()).limit(limit).options(selectinload(ActivityLog.user)) | |
| result = await db.execute(stmt) | |
| logs = result.scalars().all() | |
| formatted = [] | |
| for log in logs: | |
| name = log.user.full_name if log.user and log.user.full_name else log.user_name or "System" | |
| formatted.append({ | |
| "id": str(log.id), | |
| "user_id": str(log.user_id), | |
| "user_name": name, | |
| "action": log.action, | |
| "detail": log.detail, | |
| "timestamp": log.timestamp.isoformat() | |
| }) | |
| if not formatted: | |
| # Fallback sample data if empty | |
| now = datetime.now() | |
| samples = [ | |
| ("Naveenkumar", "file_upload", "Uploaded sales_q3.csv (12,450 rows)"), | |
| ("DataVision AI", "anomaly", "Detected 3 anomalies in revenue data"), | |
| ("Naveenkumar", "model_train", "Trained XGBoost model — 94.2% accuracy"), | |
| ("DataVision AI", "report", "Generated Executive Summary report"), | |
| ] | |
| from datetime import timedelta | |
| for i, (user, action, detail) in enumerate(samples): | |
| formatted.append({ | |
| "id": str(i + 1), | |
| "user_id": str(user_id), | |
| "user_name": user, | |
| "action": action, | |
| "detail": detail, | |
| "timestamp": (now - timedelta(hours=i * 3)).isoformat(), | |
| }) | |
| return {"activities": formatted} | |
| async def email_config_status(): | |
| """Check if email sending is configured (for frontend feedback).""" | |
| import os | |
| resend_key = os.getenv("RESEND_API_KEY", "") | |
| smtp_host = os.getenv("SMTP_HOST", "") | |
| configured = bool(resend_key) or bool(smtp_host) | |
| provider = "Resend API" if resend_key else ("SMTP" if smtp_host else "None") | |
| return { | |
| "configured": configured, | |
| "provider": provider, | |
| "message": "Email sending is active" if configured else "No email provider configured. Set RESEND_API_KEY or SMTP_HOST in environment variables." | |
| } | |