diff --git a/backend/app/api/v1/integration/router.py b/backend/app/api/v1/integration/router.py index 3afa84b7..47e0f24d 100644 --- a/backend/app/api/v1/integration/router.py +++ b/backend/app/api/v1/integration/router.py @@ -4,7 +4,7 @@ import logging import uuid -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, Header, HTTPException, Query, status @@ -29,6 +29,12 @@ TeamInfo, UserInfo, ) + +# Import the analysis response models from the Slack API +from app.api.v1.slack.analysis import ( + AnalysisResponse, + StoredAnalysisResponse, +) from app.core.auth import get_current_user from app.db.session import get_async_db from app.models.integration import ( @@ -40,10 +46,18 @@ ServiceResource, ShareLevel, ) -from app.models.slack import SlackChannel, SlackWorkspace +from app.models.slack import SlackChannel, SlackChannelAnalysis, SlackWorkspace from app.services.integration.base import IntegrationService from app.services.integration.slack import SlackIntegrationService +from app.services.llm.analysis_store import AnalysisStoreService +from app.services.llm.openrouter import OpenRouterService +from app.services.slack.api import SlackApiError from app.services.slack.channels import ChannelService +from app.services.slack.messages import ( + SlackMessageService, + get_channel_messages, + get_channel_users, +) from app.services.team.permissions import has_team_permission logger = logging.getLogger(__name__) @@ -794,6 +808,196 @@ async def sync_integration_resources( ) +@router.post( + "/{integration_id}/resources/{resource_id}/sync-messages", response_model=Dict +) +async def sync_resource_messages( + integration_id: uuid.UUID, + resource_id: uuid.UUID, + start_date: Optional[datetime] = Query( + None, description="Start date for messages to sync (defaults to 30 days ago)" + ), + end_date: Optional[datetime] = Query( + None, description="End date for messages to sync (defaults to current date)" + ), + include_replies: bool = Query( + True, description="Whether to include thread replies in the sync" + ), + db: AsyncSession = Depends(get_async_db), + current_user: Dict = Depends(get_current_user), +): + """ + Sync messages for a specific channel resource associated with an integration. + + This endpoint is specifically designed for syncing Slack channel messages before running analysis. + It ensures the most up-to-date messages are available in the database. + + Args: + integration_id: UUID of the integration + resource_id: UUID of the resource (channel) + start_date: Optional start date for messages to sync + end_date: Optional end date for messages to sync + include_replies: Whether to include thread replies + db: Database session + current_user: Current authenticated user + + Returns: + Status message with sync statistics + """ + try: + # Default to last 30 days if dates not provided + if not end_date: + end_date = datetime.utcnow() + if not start_date: + start_date = end_date - timedelta(days=30) + + # Get the integration + integration = await IntegrationService.get_integration( + db=db, + integration_id=integration_id, + user_id=current_user["id"], + ) + + if not integration: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Integration not found", + ) + + # Verify this is a Slack integration + if integration.service_type != IntegrationType.SLACK: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This operation is only supported for Slack integrations", + ) + + # Get the resource + resource_stmt = await db.execute( + select(ServiceResource).where( + ServiceResource.id == resource_id, + ServiceResource.integration_id == integration_id, + ServiceResource.resource_type == ResourceType.SLACK_CHANNEL, + ) + ) + resource = resource_stmt.scalar_one_or_none() + + if not resource: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Resource not found or not a Slack channel", + ) + + # Get the Slack workspace ID from the integration metadata + metadata = integration.integration_metadata or {} + slack_workspace_id = metadata.get("slack_id") + + if not slack_workspace_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Integration has no associated Slack workspace", + ) + + # Get the workspace from the database + workspace_result = await db.execute( + select(SlackWorkspace).where(SlackWorkspace.slack_id == slack_workspace_id) + ) + workspace = workspace_result.scalars().first() + + if not workspace: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Slack workspace not found", + ) + + # Get the channel from the database + # First, try to get the SlackChannel record + channel_result = await db.execute( + select(SlackChannel).where(SlackChannel.id == resource_id) + ) + channel = channel_result.scalars().first() + + # If no SlackChannel record exists, try to create one from the resource + if not channel: + # Create a new SlackChannel record from the ServiceResource + logger.info(f"Creating new SlackChannel record for resource {resource_id}") + channel = SlackChannel( + id=resource.id, + workspace_id=workspace.id, + slack_id=resource.external_id, + name=resource.name.lstrip("#"), # Remove # prefix if present + type=( + resource.resource_metadata.get("type", "public") + if resource.resource_metadata + else "public" + ), + is_selected_for_analysis=True, # Mark as selected since we're analyzing it + is_supported=True, + purpose=( + resource.resource_metadata.get("purpose", "") + if resource.resource_metadata + else "" + ), + topic=( + resource.resource_metadata.get("topic", "") + if resource.resource_metadata + else "" + ), + member_count=( + resource.resource_metadata.get("member_count", 0) + if resource.resource_metadata + else 0 + ), + is_archived=( + resource.resource_metadata.get("is_archived", False) + if resource.resource_metadata + else False + ), + last_sync_at=datetime.utcnow(), + ) + db.add(channel) + await db.commit() + await db.refresh(channel) + logger.info( + f"Created new SlackChannel record: {channel.id} - {channel.name}" + ) + + # Sync channel messages using the SlackMessageService + sync_results = await SlackMessageService.sync_channel_messages( + db=db, + workspace_id=str(workspace.id), + channel_id=str(channel.id), + start_date=start_date, + end_date=end_date, + include_replies=include_replies, + sync_threads=include_replies, # Sync thread replies if requested + ) + + # Update the channel's last_sync_at + channel.last_sync_at = datetime.utcnow() + await db.commit() + + return { + "status": "success", + "message": "Channel messages synced successfully", + "sync_results": sync_results, + } + + except HTTPException: + # Re-raise HTTP exceptions + raise + except ValueError as e: + # Handle specific known errors + logger.error(f"Error syncing channel messages: {str(e)}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: + # Log and raise a generic error + logger.error(f"Error syncing channel messages: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error syncing channel messages: {str(e)}", + ) + + @router.post("/{integration_id}/share", response_model=IntegrationShareResponse) async def share_integration( integration_id: uuid.UUID, @@ -1142,3 +1346,919 @@ async def select_channels_for_integration( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while selecting channels for analysis", ) + + +@router.post( + "/{integration_id}/resources/{resource_id}/sync-messages", + summary="Sync messages for a specific channel via team integration before analysis", + description="Syncs messages for a Slack channel associated with a team integration to ensure the latest messages are available for analysis.", +) +async def sync_integration_resource_messages( + integration_id: uuid.UUID, + resource_id: uuid.UUID, + start_date: Optional[datetime] = Query( + None, description="Start date for syncing messages (defaults to 30 days ago)" + ), + end_date: Optional[datetime] = Query( + None, description="End date for syncing messages (defaults to current date)" + ), + include_replies: bool = Query( + True, description="Whether to include thread replies in the sync" + ), + sync_threads: bool = Query( + True, description="Whether to explicitly sync thread replies after message sync" + ), + thread_days: int = Query( + 30, ge=1, le=90, description="Number of days of thread messages to sync" + ), + db: AsyncSession = Depends(get_async_db), + current_user: Dict = Depends(get_current_user), +): + """ + Sync messages for a Slack channel to ensure data is up-to-date for analysis. + + This endpoint: + 1. Validates that the resource is a Slack channel associated with the integration + 2. Initiates message synchronization for the channel + 3. Returns synchronization statistics + """ + # Log basic request information + logger.info( + f"Received sync-messages request: integration_id={integration_id}, resource_id={resource_id}" + ) + + # Initialize variables outside the try block for error handling + workspace = None + channel = None + + try: + # Get the integration + integration = await IntegrationService.get_integration( + db=db, + integration_id=integration_id, + user_id=current_user["id"], + ) + + if not integration: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Integration not found", + ) + + # Verify this is a Slack integration + if integration.service_type != IntegrationType.SLACK: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This operation is only supported for Slack integrations", + ) + + # Get the resource + resource_stmt = await db.execute( + select(ServiceResource).where( + ServiceResource.id == resource_id, + ServiceResource.integration_id == integration_id, + ServiceResource.resource_type == ResourceType.SLACK_CHANNEL, + ) + ) + resource = resource_stmt.scalar_one_or_none() + + if not resource: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Resource not found or not a Slack channel", + ) + + # Get the Slack workspace ID from the integration metadata + metadata = integration.integration_metadata or {} + slack_workspace_id = metadata.get("slack_id") + + if not slack_workspace_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Integration has no associated Slack workspace", + ) + + # Get the workspace from the database + workspace_result = await db.execute( + select(SlackWorkspace).where(SlackWorkspace.slack_id == slack_workspace_id) + ) + workspace = workspace_result.scalars().first() + + if not workspace: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Slack workspace not found", + ) + + # Get the channel from the database + # First, try to get the SlackChannel record + channel_result = await db.execute( + select(SlackChannel).where(SlackChannel.id == resource_id) + ) + channel = channel_result.scalars().first() + + # If no direct SlackChannel record, try to create one from the resource + if not channel: + # Create a SlackChannel record from the resource + channel = SlackChannel( + id=resource.id, + workspace_id=workspace.id, + slack_id=resource.external_id, + name=resource.name.lstrip("#"), # Remove # prefix if present + type=( + resource.resource_metadata.get("type", "public") + if resource.resource_metadata + else "public" + ), + is_selected_for_analysis=True, # Assume selected since we're analyzing it + is_supported=True, + purpose=( + resource.resource_metadata.get("purpose", "") + if resource.resource_metadata + else "" + ), + topic=( + resource.resource_metadata.get("topic", "") + if resource.resource_metadata + else "" + ), + member_count=( + resource.resource_metadata.get("member_count", 0) + if resource.resource_metadata + else 0 + ), + is_archived=( + resource.resource_metadata.get("is_archived", False) + if resource.resource_metadata + else False + ), + last_sync_at=resource.last_synced_at, + ) + db.add(channel) + await db.commit() + + # Default to last 30 days if dates not provided + if not end_date: + end_date = datetime.utcnow() + if not start_date: + start_date = end_date - timedelta(days=30) + + # Log basic operation + logger.info( + f"Syncing messages for channel {channel.name} ({channel.slack_id}) in workspace {workspace.name}" + ) + + # Use SlackMessageService to sync messages + sync_results = await SlackMessageService.sync_channel_messages( + db=db, + workspace_id=str(workspace.id), + channel_id=str(channel.id), + start_date=start_date, + end_date=end_date, + include_replies=include_replies, + sync_threads=sync_threads, + thread_days=thread_days, + ) + + # Log results summary + logger.info( + f"Sync completed: {sync_results.get('new_message_count', 0)} messages and {sync_results.get('replies_synced', 0)} thread replies synced" + ) + + # Return sync results + return { + "status": "success", + "message": f"Synced {sync_results.get('new_message_count', 0)} messages and {sync_results.get('replies_synced', 0)} thread replies", + "sync_results": sync_results, + "workspace_id": str(workspace.id), + "channel_id": str(channel.id), + } + + except SlackApiError as e: + logger.error(f"Slack API error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error from Slack API: {str(e)}", + ) + except HTTPException as http_ex: + # Re-raise HTTP exceptions + logger.warning(f"HTTP exception in sync-messages: {http_ex.detail}") + raise + except ValueError as val_err: + logger.error(f"ValueError in sync-messages: {val_err}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(val_err) + ) + except Exception as e: + logger.error(f"Unexpected error syncing messages: {str(e)}", exc_info=True) + # Return a more detailed error for debugging + try: + error_context = { + "workspace_id": str(workspace.id) if workspace else "unknown", + "channel_id": str(channel.id) if channel else "unknown", + "slack_channel_id": ( + channel.slack_id + if channel and hasattr(channel, "slack_id") + else "unknown" + ), + "error_type": type(e).__name__, + "error_message": str(e), + } + except Exception as ctx_err: + error_context = { + "context_error": f"Failed to create error context: {str(ctx_err)}", + "error_type": type(e).__name__, + "error_message": str(e), + } + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error syncing messages: {str(e)}. Context: {error_context}", + ) + + +@router.post( + "/{integration_id}/resources/{resource_id}/analyze", + response_model=AnalysisResponse, + summary="Analyze a Slack channel via team integration", + description="Uses LLM to analyze messages in a Slack channel associated with a team integration and provide insights about communication patterns, key contributors, and discussion topics.", +) +async def analyze_integration_resource( + integration_id: uuid.UUID, + resource_id: uuid.UUID, + start_date: Optional[datetime] = Query( + None, description="Start date for analysis period (defaults to 30 days ago)" + ), + end_date: Optional[datetime] = Query( + None, description="End date for analysis period (defaults to current date)" + ), + include_threads: bool = Query( + True, description="Whether to include thread replies in the analysis" + ), + include_reactions: bool = Query( + True, description="Whether to include reactions data in the analysis" + ), + model: Optional[str] = Query( + None, description="Specific LLM model to use (see OpenRouter docs)" + ), + db: AsyncSession = Depends(get_async_db), + current_user: Dict = Depends(get_current_user), +): + """ + Analyze messages in a Slack channel using LLM to provide insights. + + This endpoint: + 1. Validates that the resource is a Slack channel associated with the integration + 2. Retrieves messages for the specified channel and date range + 3. Processes messages into a format suitable for LLM analysis + 4. Sends data to OpenRouter LLM API for analysis + 5. Returns structured insights about communication patterns + + The analysis includes: + - Channel summary (purpose, activity patterns) + - Topic analysis (main discussion topics) + - Contributor insights (key contributors and their patterns) + - Key highlights (notable discussions worth attention) + """ + # Default to last 30 days if dates not provided + if not end_date: + end_date = datetime.utcnow() + if not start_date: + start_date = end_date - timedelta(days=30) + + # Create an instance of the OpenRouter service + llm_service = OpenRouterService() + + try: + # Get the integration + integration = await IntegrationService.get_integration( + db=db, + integration_id=integration_id, + user_id=current_user["id"], + ) + + if not integration: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Integration not found", + ) + + # Verify this is a Slack integration + if integration.service_type != IntegrationType.SLACK: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This operation is only supported for Slack integrations", + ) + + # Get the resource + resource_stmt = await db.execute( + select(ServiceResource).where( + ServiceResource.id == resource_id, + ServiceResource.integration_id == integration_id, + ServiceResource.resource_type == ResourceType.SLACK_CHANNEL, + ) + ) + resource = resource_stmt.scalar_one_or_none() + + if not resource: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Resource not found or not a Slack channel", + ) + + # Get the Slack workspace ID from the integration metadata + metadata = integration.integration_metadata or {} + slack_workspace_id = metadata.get("slack_id") + + if not slack_workspace_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Integration has no associated Slack workspace", + ) + + # Get the workspace from the database + workspace_result = await db.execute( + select(SlackWorkspace).where(SlackWorkspace.slack_id == slack_workspace_id) + ) + workspace = workspace_result.scalars().first() + + if not workspace: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Slack workspace not found", + ) + + # Get the channel from the database + # First, try to get the SlackChannel record + channel_result = await db.execute( + select(SlackChannel).where(SlackChannel.id == resource_id) + ) + channel = channel_result.scalars().first() + + # If no direct SlackChannel record, try to create one from the resource + if not channel: + # Create a SlackChannel record from the resource + channel = SlackChannel( + id=resource.id, + workspace_id=workspace.id, + slack_id=resource.external_id, + name=resource.name.lstrip("#"), # Remove # prefix if present + type=( + resource.resource_metadata.get("type", "public") + if resource.resource_metadata + else "public" + ), + is_selected_for_analysis=True, # Assume selected since we're analyzing it + is_supported=True, + purpose=( + resource.resource_metadata.get("purpose", "") + if resource.resource_metadata + else "" + ), + topic=( + resource.resource_metadata.get("topic", "") + if resource.resource_metadata + else "" + ), + member_count=( + resource.resource_metadata.get("member_count", 0) + if resource.resource_metadata + else 0 + ), + is_archived=( + resource.resource_metadata.get("is_archived", False) + if resource.resource_metadata + else False + ), + last_sync_at=resource.last_synced_at, + ) + db.add(channel) + await db.commit() + + # Get messages for the channel within the date range + messages = await get_channel_messages( + db, + str(workspace.id), # Use workspace UUID from database + str(channel.id), # Use channel UUID from database + start_date=start_date, + end_date=end_date, + include_replies=include_threads, + ) + + # Get user data for the channel + users = await get_channel_users(db, str(workspace.id), str(channel.id)) + + # Process messages and add user data + processed_messages = [] + user_dict = {user.slack_id: user for user in users} + message_count = 0 + thread_count = 0 + reaction_count = 0 + participant_set = set() + + for msg in messages: + message_count += 1 + if msg.user_id: + participant_set.add(msg.user_id) + + if msg.is_thread_parent: + thread_count += 1 + + if msg.reaction_count: + reaction_count += msg.reaction_count + + user = user_dict.get(msg.user_id) if msg.user_id else None + user_name = user.display_name or user.name if user else "Unknown User" + + processed_messages.append( + { + "id": msg.id, + "user_id": msg.user_id, + "user_name": user_name, + "text": msg.text, + "timestamp": msg.message_datetime.isoformat(), + "is_thread_parent": msg.is_thread_parent, + "is_thread_reply": msg.is_thread_reply, + "thread_ts": msg.thread_ts, + "has_attachments": msg.has_attachments, + "reaction_count": msg.reaction_count, + } + ) + + # Prepare data for LLM analysis + messages_data = { + "message_count": message_count, + "participant_count": len(participant_set), + "thread_count": thread_count, + "reaction_count": reaction_count, + "messages": processed_messages, + } + + # Call the LLM service to analyze the data + analysis_results = await llm_service.analyze_channel_messages( + channel_name=channel.name, + messages_data=messages_data, + start_date=start_date, + end_date=end_date, + model=model, + ) + + # Store analysis results in the database + stats = { + "message_count": message_count, + "participant_count": len(participant_set), + "thread_count": thread_count, + "reaction_count": reaction_count, + } + + try: + await AnalysisStoreService.store_channel_analysis( + db=db, + workspace_id=str(workspace.id), + channel_id=str(channel.id), + start_date=start_date, + end_date=end_date, + stats=stats, + analysis_results=analysis_results, + model_used=analysis_results.get("model_used", model or ""), + ) + logger.info(f"Stored analysis for channel {channel.id}") + except Exception as e: + logger.error(f"Error storing analysis results: {str(e)}") + # We'll continue with the API response even if storage fails + + # Build the response + response = AnalysisResponse( + analysis_id=f"analysis_{channel.id}_{int(datetime.utcnow().timestamp())}", + channel_id=str(channel.id), + channel_name=channel.name, + period={"start": start_date, "end": end_date}, + stats=stats, + channel_summary=analysis_results.get("channel_summary", ""), + topic_analysis=analysis_results.get("topic_analysis", ""), + contributor_insights=analysis_results.get("contributor_insights", ""), + key_highlights=analysis_results.get("key_highlights", ""), + model_used=analysis_results.get("model_used", ""), + generated_at=datetime.utcnow(), + ) + + return response + + except HTTPException: + # Re-raise HTTP exceptions + raise + except ValueError as e: + # Handle specific known errors + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: + # Log and raise a generic error + logger.error(f"Error analyzing channel: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error analyzing channel: {str(e)}", + ) + + +@router.get( + "/{integration_id}/resources/{resource_id}/analyses", + response_model=List[StoredAnalysisResponse], + summary="Get stored channel analyses for a team integration resource", + description="Retrieves previously run channel analyses for a Slack channel associated with a team integration.", +) +async def get_integration_resource_analyses( + integration_id: uuid.UUID, + resource_id: uuid.UUID, + limit: int = Query( + 10, ge=1, le=100, description="Maximum number of analyses to return" + ), + offset: int = Query(0, ge=0, description="Offset for pagination"), + db: AsyncSession = Depends(get_async_db), + current_user: Dict = Depends(get_current_user), +): + """ + Get stored channel analyses from the database for a team integration resource. + + Retrieves previously stored LLM analyses for a specific Slack channel, ordered by most recent first. + """ + try: + # Get the integration + integration = await IntegrationService.get_integration( + db=db, + integration_id=integration_id, + user_id=current_user["id"], + ) + + if not integration: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Integration not found", + ) + + # Verify this is a Slack integration + if integration.service_type != IntegrationType.SLACK: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This operation is only supported for Slack integrations", + ) + + # Get the resource + resource_stmt = await db.execute( + select(ServiceResource).where( + ServiceResource.id == resource_id, + ServiceResource.integration_id == integration_id, + ServiceResource.resource_type == ResourceType.SLACK_CHANNEL, + ) + ) + resource = resource_stmt.scalar_one_or_none() + + if not resource: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Resource not found or not a Slack channel", + ) + + # Get the channel to ensure it exists and get the channel name + channel_result = await db.execute( + select(SlackChannel).where(SlackChannel.id == resource_id) + ) + channel = channel_result.scalars().first() + + if not channel: + # Try using the resource data directly + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found in database. Please run an analysis first.", + ) + + # Get the stored analyses + analyses = await AnalysisStoreService.get_channel_analyses_for_channel( + db=db, channel_id=str(channel.id), limit=limit, offset=offset + ) + + # Convert to response model + response = [] + for analysis in analyses: + response.append( + StoredAnalysisResponse( + id=str(analysis.id), + channel_id=str(channel.id), + channel_name=channel.name, + start_date=analysis.start_date, + end_date=analysis.end_date, + message_count=analysis.message_count, + participant_count=analysis.participant_count, + thread_count=analysis.thread_count, + reaction_count=analysis.reaction_count, + channel_summary=analysis.channel_summary, + topic_analysis=analysis.topic_analysis, + contributor_insights=analysis.contributor_insights, + key_highlights=analysis.key_highlights, + model_used=analysis.model_used, + generated_at=analysis.generated_at, + ) + ) + + return response + + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + logger.error(f"Error retrieving stored analyses: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving stored analyses: {str(e)}", + ) + + +@router.get( + "/{integration_id}/resources/{resource_id}/analyses/latest", + response_model=Optional[StoredAnalysisResponse], + summary="Get latest channel analysis for a team integration resource", + description="Retrieves the most recent channel analysis for a Slack channel associated with a team integration.", +) +async def get_latest_integration_resource_analysis( + integration_id: uuid.UUID, + resource_id: uuid.UUID, + db: AsyncSession = Depends(get_async_db), + current_user: Dict = Depends(get_current_user), +): + """ + Get the most recent channel analysis from the database for a team integration resource. + + Retrieves the latest LLM analysis for a specific Slack channel. + """ + try: + # Get the integration + integration = await IntegrationService.get_integration( + db=db, + integration_id=integration_id, + user_id=current_user["id"], + ) + + if not integration: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Integration not found", + ) + + # Verify this is a Slack integration + if integration.service_type != IntegrationType.SLACK: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This operation is only supported for Slack integrations", + ) + + # Get the resource + resource_stmt = await db.execute( + select(ServiceResource).where( + ServiceResource.id == resource_id, + ServiceResource.integration_id == integration_id, + ServiceResource.resource_type == ResourceType.SLACK_CHANNEL, + ) + ) + resource = resource_stmt.scalar_one_or_none() + + if not resource: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Resource not found or not a Slack channel", + ) + + # Get the channel to ensure it exists and get the channel name + channel_result = await db.execute( + select(SlackChannel).where(SlackChannel.id == resource_id) + ) + channel = channel_result.scalars().first() + + if not channel: + # Try using the resource data directly + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found in database. Please run an analysis first.", + ) + + # Get the latest analysis + analysis = await AnalysisStoreService.get_latest_channel_analysis( + db=db, + channel_id=str(channel.id), + ) + + if not analysis: + return None + + # Convert to response model + return StoredAnalysisResponse( + id=str(analysis.id), + channel_id=str(channel.id), + channel_name=channel.name, + start_date=analysis.start_date, + end_date=analysis.end_date, + message_count=analysis.message_count, + participant_count=analysis.participant_count, + thread_count=analysis.thread_count, + reaction_count=analysis.reaction_count, + channel_summary=analysis.channel_summary, + topic_analysis=analysis.topic_analysis, + contributor_insights=analysis.contributor_insights, + key_highlights=analysis.key_highlights, + model_used=analysis.model_used, + generated_at=analysis.generated_at, + ) + + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + logger.error(f"Error retrieving latest analysis: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving latest analysis: {str(e)}", + ) + + +@router.get( + "/{integration_id}/resources/{resource_id}/analysis/{analysis_id}", + response_model=StoredAnalysisResponse, + summary="Get a specific channel analysis for a team integration resource", + description="Retrieves a specific channel analysis by ID for a Slack channel associated with a team integration.", +) +async def get_integration_resource_analysis( + integration_id: uuid.UUID, + resource_id: uuid.UUID, + analysis_id: str, + db: AsyncSession = Depends(get_async_db), + current_user: Dict = Depends(get_current_user), +): + """ + Get a specific channel analysis by ID from the database. + + Retrieves a specific stored LLM analysis for a channel by its ID. + """ + try: + # Get the integration + integration = await IntegrationService.get_integration( + db=db, + integration_id=integration_id, + user_id=current_user["id"], + ) + + if not integration: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Integration not found", + ) + + # Verify this is a Slack integration + if integration.service_type != IntegrationType.SLACK: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This operation is only supported for Slack integrations", + ) + + # Get the resource + resource_stmt = await db.execute( + select(ServiceResource).where( + ServiceResource.id == resource_id, + ServiceResource.integration_id == integration_id, + ServiceResource.resource_type == ResourceType.SLACK_CHANNEL, + ) + ) + resource = resource_stmt.scalar_one_or_none() + + if not resource: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Resource not found or not a Slack channel", + ) + + # Get the channel to ensure it exists + channel_result = await db.execute( + select(SlackChannel).where(SlackChannel.id == resource_id) + ) + channel = channel_result.scalars().first() + + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found in database.", + ) + + # Extract the real analysis ID from the formatted string + # analysis_id format: analysis_{channel_id}_{timestamp} + real_analysis_id = None + try: + # If the analysis_id is a UUID, use it directly + uuid_obj = uuid.UUID(analysis_id) + real_analysis_id = str(uuid_obj) + except ValueError: + # If the analysis_id is not a UUID, it might be in the format we generate + # Try to lookup the analysis by composite key + parts = analysis_id.split("_") + if len(parts) >= 3 and parts[0] == "analysis": + # Try to extract timestamp + timestamp_str = parts[-1] + try: + # Convert timestamp to datetime + timestamp = int(timestamp_str) + + # Find the analysis closest to this timestamp + stmt = ( + select(SlackChannelAnalysis) + .where(SlackChannelAnalysis.channel_id == channel.id) + .order_by( + func.abs( + func.extract("epoch", SlackChannelAnalysis.generated_at) + - timestamp + ) + ) + .limit(1) + ) + + result = await db.execute(stmt) + analysis = result.scalar_one_or_none() + + if analysis: + real_analysis_id = str(analysis.id) + logger.info(f"Found analysis by timestamp: {real_analysis_id}") + else: + logger.warning( + f"No analysis found near timestamp {timestamp_str}" + ) + except (ValueError, TypeError): + logger.warning( + f"Could not parse timestamp from analysis_id: {analysis_id}" + ) + + if not real_analysis_id: + # As fallback, try to get the latest analysis + logger.info( + f"Using fallback to get latest analysis for channel {channel.id}" + ) + analysis = await AnalysisStoreService.get_latest_channel_analysis( + db=db, + channel_id=str(channel.id), + ) + + if analysis: + real_analysis_id = str(analysis.id) + logger.info( + f"Using latest analysis as fallback: {real_analysis_id}" + ) + + # If we couldn't extract an ID or find an analysis, return 404 + if not real_analysis_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Analysis not found", + ) + + # Get the analysis by ID + stmt = select(SlackChannelAnalysis).where( + SlackChannelAnalysis.id == real_analysis_id + ) + result = await db.execute(stmt) + analysis = result.scalar_one_or_none() + + if not analysis: + # Try getting it directly by the ID that was passed + stmt = select(SlackChannelAnalysis).where( + SlackChannelAnalysis.id == analysis_id + ) + result = await db.execute(stmt) + analysis = result.scalar_one_or_none() + + if not analysis: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Analysis not found", + ) + + # Return the analysis + return StoredAnalysisResponse( + id=str(analysis.id), + channel_id=str(channel.id), + channel_name=channel.name, + start_date=analysis.start_date, + end_date=analysis.end_date, + message_count=analysis.message_count, + participant_count=analysis.participant_count, + thread_count=analysis.thread_count, + reaction_count=analysis.reaction_count, + channel_summary=analysis.channel_summary, + topic_analysis=analysis.topic_analysis, + contributor_insights=analysis.contributor_insights, + key_highlights=analysis.key_highlights, + model_used=analysis.model_used, + generated_at=analysis.generated_at, + ) + + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + logger.error(f"Error retrieving analysis: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving analysis: {str(e)}", + ) diff --git a/backend/app/services/slack/channels.py b/backend/app/services/slack/channels.py index ff6b8292..21c28e5a 100644 --- a/backend/app/services/slack/channels.py +++ b/backend/app/services/slack/channels.py @@ -502,7 +502,7 @@ async def select_channels_for_analysis( if install_bot: api_client = SlackApiClient(workspace.access_token) - # Unselect all channels if we are setting for_analysis=True + # Unselect all channels if we are setting for_analysis=True # This is for backward compatibility with old behavior if for_analysis: # First, unselect all channels diff --git a/backend/tests/services/slack/test_channels.py b/backend/tests/services/slack/test_channels.py index 5b4a1b79..c5c26894 100644 --- a/backend/tests/services/slack/test_channels.py +++ b/backend/tests/services/slack/test_channels.py @@ -275,7 +275,9 @@ async def test_select_channels_for_analysis_without_bot_install( assert "bot_installation" not in result # Verify the db operations - assert mock_db_session.execute.call_count == 5 # Including all_channels query for debugging + assert ( + mock_db_session.execute.call_count == 5 + ) # Including all_channels query for debugging assert mock_db_session.commit.called @@ -371,5 +373,7 @@ async def test_select_channels_for_analysis_with_bot_install( mock_client.join_channel.assert_called_once_with(channel_without_bot.slack_id) # Verify the db operations - assert mock_db_session.execute.call_count == 5 # Including all_channels query for debugging + assert ( + mock_db_session.execute.call_count == 5 + ) # Including all_channels query for debugging assert mock_db_session.commit.called diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 16ba9390..5fbd41ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,9 @@ import { IntegrationDetailPage, IntegrationConnectPage, TeamChannelSelectorPage, + TeamChannelAnalysisPage, + TeamAnalysisResultPage, + TeamChannelAnalysisHistoryPage, } from './pages/integration' // Profile Pages @@ -266,6 +269,36 @@ function App() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> {/* Profile routes */} ({ + useNavigate: () => vi.fn(), + Link: ({ children }: { children: React.ReactNode }) => children, +})) + // Mock the useIntegration hook functionality through context const mockIntegrationContext = { // State diff --git a/frontend/src/__tests__/context/IntegrationContext.test.tsx b/frontend/src/__tests__/context/IntegrationContext.test.tsx index f064ff2a..a2b63b1a 100644 --- a/frontend/src/__tests__/context/IntegrationContext.test.tsx +++ b/frontend/src/__tests__/context/IntegrationContext.test.tsx @@ -37,6 +37,7 @@ vi.mock('../../lib/integrationService', () => ({ getSelectedChannels: vi.fn(), selectChannelsForAnalysis: vi.fn(), analyzeChannel: vi.fn(), + analyzeResource: vi.fn(), }, IntegrationType: { SLACK: 'slack', @@ -221,7 +222,7 @@ describe('IntegrationContext', () => { status: 'success', message: 'Channels selected for analysis', }) - vi.mocked(integrationService.analyzeChannel).mockResolvedValue( + vi.mocked(integrationService.analyzeResource).mockResolvedValue( mockAnalysisResult ) @@ -706,7 +707,7 @@ describe('IntegrationContext', () => { }) // Check that the service was called correctly - expect(integrationService.analyzeChannel).toHaveBeenCalledWith( + expect(integrationService.analyzeResource).toHaveBeenCalledWith( 'test-int-1', 'res-1', options diff --git a/frontend/src/components/integration/TeamChannelSelector.tsx b/frontend/src/components/integration/TeamChannelSelector.tsx index f01a0bbd..4c1ab2d6 100644 --- a/frontend/src/components/integration/TeamChannelSelector.tsx +++ b/frontend/src/components/integration/TeamChannelSelector.tsx @@ -20,8 +20,16 @@ import { Button, Flex, useToast, + Tooltip, } from '@chakra-ui/react' -import { FiSearch, FiSettings, FiCheck, FiBarChart2 } from 'react-icons/fi' +import { + FiSearch, + FiSettings, + FiCheck, + FiBarChart2, + FiClock, +} from 'react-icons/fi' +import { useNavigate } from 'react-router-dom' import { ResourceType } from '../../lib/integrationService' import useIntegration from '../../context/useIntegration' @@ -36,6 +44,7 @@ const TeamChannelSelector: React.FC = ({ integrationId, }) => { const toast = useToast() + const navigate = useNavigate() const { currentResources, loadingResources, @@ -348,20 +357,43 @@ const TeamChannelSelector: React.FC = ({ - } - size="sm" - variant="ghost" - title="View analysis" - /> - } - size="sm" - variant="ghost" - title="Settings" - /> + + } + size="sm" + variant="ghost" + colorScheme="blue" + onClick={() => + navigate( + `/dashboard/integrations/${integrationId}/channels/${channel.id}/analyze` + ) + } + /> + + + } + size="sm" + variant="ghost" + colorScheme="teal" + onClick={() => + navigate( + `/dashboard/integrations/${integrationId}/channels/${channel.id}/history` + ) + } + /> + + + } + size="sm" + variant="ghost" + title="Settings" + /> + diff --git a/frontend/src/context/IntegrationContext.tsx b/frontend/src/context/IntegrationContext.tsx index 7fd20f62..c0c8c817 100644 --- a/frontend/src/context/IntegrationContext.tsx +++ b/frontend/src/context/IntegrationContext.tsx @@ -1095,7 +1095,7 @@ export const IntegrationProvider: React.FC<{ children: React.ReactNode }> = ({ })) try { - const result = await integrationService.analyzeChannel( + const result = await integrationService.analyzeResource( integrationId, channelId, options @@ -1115,7 +1115,12 @@ export const IntegrationProvider: React.FC<{ children: React.ReactNode }> = ({ loadingChannelSelection: false, })) - return result + // Cast the result to the expected return type + return { + status: 'success', + analysis_id: + (result as { analysis_id?: string }).analysis_id || 'unknown', + } } catch (error) { setState((prev) => ({ ...prev, diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index 61402b5c..c1b990f4 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -32,7 +32,7 @@ export class ApiClient { protected async get( path: string, params?: Record - ): Promise { + ): Promise { const url = new URL(`${this.baseUrl}${path}`) if (params) { @@ -43,43 +43,91 @@ export class ApiClient { }) } - const response = await fetch(url.toString(), { - method: 'GET', - credentials: 'include', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - }) + try { + const response = await fetch(url.toString(), { + method: 'GET', + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) - if (!response.ok) { - throw new Error(`API Error: ${response.status} ${response.statusText}`) - } + // If the response is not OK, return an ApiError instead of throwing + if (!response.ok) { + let errorDetail: string + try { + // Try to parse error details from response + const errorJson = await response.json() + errorDetail = + errorJson.detail || errorJson.message || response.statusText + } catch { + // If can't parse JSON, use status text + errorDetail = response.statusText + } - return response.json() + return { + status: response.status, + message: `API Error: ${response.status} ${response.statusText}`, + detail: errorDetail, + } as ApiError + } + + return response.json() + } catch (error) { + // Network errors or other fetch exceptions + return { + status: 'NETWORK_ERROR', + message: `Network Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + } as ApiError + } } // Standard POST request protected async post( path: string, data?: Record - ): Promise { + ): Promise { const url = `${this.baseUrl}${path}` - const response = await fetch(url, { - method: 'POST', - credentials: 'include', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: data ? JSON.stringify(data) : undefined, - }) + try { + const response = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: data ? JSON.stringify(data) : undefined, + }) - if (!response.ok) { - throw new Error(`API Error: ${response.status} ${response.statusText}`) - } + // If the response is not OK, return an ApiError instead of throwing + if (!response.ok) { + let errorDetail: string + try { + // Try to parse error details from response + const errorJson = await response.json() + errorDetail = + errorJson.detail || errorJson.message || response.statusText + } catch { + // If can't parse JSON, use status text + errorDetail = response.statusText + } - return response.json() + return { + status: response.status, + message: `API Error: ${response.status} ${response.statusText}`, + detail: errorDetail, + } as ApiError + } + + return response.json() + } catch (error) { + // Network errors or other fetch exceptions + return { + status: 'NETWORK_ERROR', + message: `Network Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + } as ApiError + } } } diff --git a/frontend/src/lib/integrationService.ts b/frontend/src/lib/integrationService.ts index 8dd69bf2..925f1d0a 100644 --- a/frontend/src/lib/integrationService.ts +++ b/frontend/src/lib/integrationService.ts @@ -79,6 +79,7 @@ export interface Integration { metadata?: Record last_used_at?: string updated?: boolean // Flag indicating if this was an update to an existing integration + workspace_id?: string // External service identifier (e.g., Slack workspace ID) owner_team: TeamInfo created_by: UserInfo @@ -180,6 +181,7 @@ export interface AnalysisOptions { end_date?: string include_threads?: boolean include_reactions?: boolean + analysis_type?: string } // Error types @@ -187,13 +189,15 @@ export interface ApiError { status: number message: string details?: unknown + detail?: string } /** * Integration Service class */ class IntegrationService { - private apiUrl: string + // Make apiUrl public so it can be used for custom endpoints + public apiUrl: string constructor() { this.apiUrl = `${env.apiUrl}/integrations` @@ -201,8 +205,9 @@ class IntegrationService { /** * Helper method to create auth headers + * Made public to allow custom API calls to integration endpoints */ - private async getAuthHeaders(): Promise { + public async getAuthHeaders(): Promise { const { data: { session }, } = await supabase.auth.getSession() @@ -419,6 +424,44 @@ class IntegrationService { } } + /** + * Get a specific resource by ID + * Note: This method fetches all resources and filters for the specific one, + * as there's no direct API endpoint for fetching a single resource by ID. + */ + async getResource( + integrationId: string, + resourceId: string + ): Promise { + try { + console.log( + `[DEBUG] Fetching resource ${resourceId} for integration ${integrationId}` + ) + + // Fetch all resources and filter for the specific one + const resources = await this.getResources(integrationId) + + // Check if we got an error from getResources + if (this.isApiError(resources)) { + console.error('[DEBUG] Error fetching resources:', resources.message) + throw new Error(`Failed to fetch resources: ${resources.message}`) + } + + // Filter for the specific resource + const resource = resources.find((res) => res.id === resourceId) + + if (!resource) { + console.error(`[DEBUG] Resource ${resourceId} not found in resources`) + throw new Error(`Resource ${resourceId} not found`) + } + + console.log(`[DEBUG] Found resource:`, resource) + return resource + } catch (error) { + return this.handleError(error, 'Failed to fetch resource') + } + } + /** * Sync integration resources */ @@ -726,33 +769,219 @@ class IntegrationService { } /** - * Run analysis on a channel - * Initiates an analysis job for the specified channel + * Analyze a resource (channel) through the team integration + * @param integrationId Integration UUID + * @param resourceId Resource UUID (channel) + * @param options Analysis options */ - async analyzeChannel( + async analyzeResource( integrationId: string, - channelId: string, - options: AnalysisOptions - ): Promise<{ status: string; analysis_id: string } | ApiError> { + resourceId: string, + options?: AnalysisOptions + ): Promise | ApiError> { try { + console.log( + `[DEBUG] Analyzing resource ${resourceId} for integration ${integrationId}` + ) + const headers = await this.getAuthHeaders() - const response = await fetch( - `${this.apiUrl}/${integrationId}/resources/channels/${channelId}/analyze`, - { - method: 'POST', - headers, - credentials: 'include', - body: JSON.stringify(options), + const url = `${this.apiUrl}/${integrationId}/resources/${resourceId}/analyze` + + // Log the API URL being called + console.log(`[DEBUG] Analyzing resource with URL: ${url}`) + + // Create request body with analysis options + const body = options || {} + + // Make the API call + const response = await fetch(url, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(body), + }) + + if (!response.ok) { + // Try to extract more detailed error information + let errorDetail = '' + try { + const errorText = await response.text() + const errorJson = JSON.parse(errorText) + errorDetail = errorJson.detail || errorText + } catch { + errorDetail = response.statusText + } + + return { + status: response.status, + message: `Analysis request failed: ${response.status} ${response.statusText}`, + detail: errorDetail, + } + } + + return await response.json() + } catch (error) { + return this.handleError(error, 'Failed to analyze resource') + } + } + + /** + * Get analysis history for a resource + * @param integrationId Integration UUID + * @param resourceId Resource UUID (channel) + */ + async getResourceAnalyses( + integrationId: string, + resourceId: string + ): Promise[] | ApiError> { + try { + console.log(`[DEBUG] Getting analysis history for resource ${resourceId}`) + + const headers = await this.getAuthHeaders() + const url = `${this.apiUrl}/${integrationId}/resources/${resourceId}/analyses` + + console.log(`[DEBUG] Getting analysis history with URL: ${url}`) + + // Make the API call + const response = await fetch(url, { + method: 'GET', + headers: { + ...headers, + Accept: 'application/json', + }, + credentials: 'include', + }) + + if (!response.ok) { + // Try to extract more detailed error information + let errorDetail = '' + try { + const errorText = await response.text() + const errorJson = JSON.parse(errorText) + errorDetail = errorJson.detail || errorText + } catch { + errorDetail = response.statusText + } + + return { + status: response.status, + message: `Failed to retrieve analysis history: ${response.status} ${response.statusText}`, + detail: errorDetail, + } + } + + return await response.json() + } catch (error) { + return this.handleError(error, 'Failed to get resource analyses') + } + } + + /** + * Get latest analysis for a resource + * @param integrationId Integration UUID + * @param resourceId Resource UUID (channel) + */ + async getLatestResourceAnalysis( + integrationId: string, + resourceId: string + ): Promise | ApiError> { + try { + console.log(`[DEBUG] Getting latest analysis for resource ${resourceId}`) + + const headers = await this.getAuthHeaders() + const url = `${this.apiUrl}/${integrationId}/resources/${resourceId}/analyses/latest` + + console.log(`[DEBUG] Getting latest analysis with URL: ${url}`) + + // Make the API call + const response = await fetch(url, { + method: 'GET', + headers: { + ...headers, + Accept: 'application/json', + }, + credentials: 'include', + }) + + if (!response.ok) { + // Try to extract more detailed error information + let errorDetail = '' + try { + const errorText = await response.text() + const errorJson = JSON.parse(errorText) + errorDetail = errorJson.detail || errorText + } catch { + errorDetail = response.statusText } + + return { + status: response.status, + message: `Failed to retrieve latest analysis: ${response.status} ${response.statusText}`, + detail: errorDetail, + } + } + + return await response.json() + } catch (error) { + return this.handleError(error, 'Failed to get latest resource analysis') + } + } + + /** + * Get a specific analysis for a resource + * @param integrationId Integration UUID + * @param resourceId Resource UUID (channel) + * @param analysisId Analysis ID + */ + async getResourceAnalysis( + integrationId: string, + resourceId: string, + analysisId: string + ): Promise | ApiError> { + try { + console.log( + `[DEBUG] Getting analysis ${analysisId} for resource ${resourceId}` ) + const headers = await this.getAuthHeaders() + const url = `${this.apiUrl}/${integrationId}/resources/${resourceId}/analysis/${analysisId}` + + console.log(`[DEBUG] Getting analysis with URL: ${url}`) + + // Make the API call + const response = await fetch(url, { + method: 'GET', + headers: { + ...headers, + Accept: 'application/json', + }, + credentials: 'include', + }) + if (!response.ok) { - throw response + // Try to extract more detailed error information + let errorDetail = '' + try { + const errorText = await response.text() + const errorJson = JSON.parse(errorText) + errorDetail = errorJson.detail || errorText + } catch { + errorDetail = response.statusText + } + + return { + status: response.status, + message: `Failed to retrieve analysis: ${response.status} ${response.statusText}`, + detail: errorDetail, + } } return await response.json() } catch (error) { - return this.handleError(error, 'Failed to analyze channel') + return this.handleError(error, 'Failed to get resource analysis') } } diff --git a/frontend/src/lib/slackApiClient.ts b/frontend/src/lib/slackApiClient.ts index d6723945..7d33c03d 100644 --- a/frontend/src/lib/slackApiClient.ts +++ b/frontend/src/lib/slackApiClient.ts @@ -59,10 +59,28 @@ export interface SlackMessage { } export interface SlackAnalysisResult { + analysis_id: string channel_id: string + channel_name: string analysis_type: string result: Record created_at: string + generated_at?: string + period?: { + start: string + end: string + } + stats?: { + message_count: number + participant_count: number + thread_count: number + reaction_count: number + } + channel_summary?: string + topic_analysis?: string + contributor_insights?: string + key_highlights?: string + model_used?: string } export interface SlackOAuthRequest { @@ -72,18 +90,39 @@ export interface SlackOAuthRequest { client_secret: string } +// Import env config +import env from '../config/env' + // Slack API client class class SlackApiClient extends ApiClient { + // Store the calculated base URL for logging + private apiBaseUrl: string + constructor() { - // Pass the slack path to the base class - super('/integrations/slack') + // Use the full API URL with the slack path + // The baseUrl should include the protocol, host, and API prefix + const baseUrl = `${env.apiUrl}/slack` + super(baseUrl) + + // Store the base URL for logging + this.apiBaseUrl = baseUrl + + // Debug information about API URL construction + console.log('SlackApiClient debug info:') + console.log('- env.apiUrl:', env.apiUrl) + console.log('- Full base URL:', baseUrl) + console.log( + '- Example POST endpoint:', + `${baseUrl}/workspaces/{workspace_id}/channels/{channel_id}/analyze` + ) + console.log('- API client will prepend "/" to any API paths.') } /** * Get all Slack workspaces for the current team */ async getWorkspaces(teamId?: string): Promise { - const endpoint = teamId ? `?team_id=${teamId}` : '' + const endpoint = teamId ? `workspaces?team_id=${teamId}` : 'workspaces' return this.get(endpoint) } @@ -91,7 +130,7 @@ class SlackApiClient extends ApiClient { * Get a single Slack workspace */ async getWorkspace(workspaceId: string): Promise { - return this.get(`${workspaceId}`) + return this.get(`workspaces/${workspaceId}`) } /** @@ -109,8 +148,7 @@ class SlackApiClient extends ApiClient { team_id: teamId, } - // Since we're already on the /integrations/slack path, we should use empty string - // to avoid creating a duplicate /slack in the URL + // Use empty string to hit the root /api/v1/slack endpoint return this.post>('', data) } @@ -118,7 +156,7 @@ class SlackApiClient extends ApiClient { * Get all channels for a workspace */ async getChannels(workspaceId: string): Promise { - return this.get(`${workspaceId}/channels`) + return this.get(`workspaces/${workspaceId}/channels`) } /** @@ -128,7 +166,9 @@ class SlackApiClient extends ApiClient { workspaceId: string, channelId: string ): Promise { - return this.get(`${workspaceId}/channels/${channelId}`) + return this.get( + `workspaces/${workspaceId}/channels/${channelId}` + ) } /** @@ -141,7 +181,7 @@ class SlackApiClient extends ApiClient { offset: number = 0 ): Promise { return this.get( - `${workspaceId}/channels/${channelId}/messages?limit=${limit}&offset=${offset}` + `workspaces/${workspaceId}/channels/${channelId}/messages?limit=${limit}&offset=${offset}` ) } @@ -154,7 +194,7 @@ class SlackApiClient extends ApiClient { threadTs: string ): Promise { return this.get( - `${workspaceId}/channels/${channelId}/threads/${threadTs}` + `workspaces/${workspaceId}/channels/${channelId}/threads/${threadTs}` ) } @@ -162,7 +202,7 @@ class SlackApiClient extends ApiClient { * Get all users for a workspace */ async getUsers(workspaceId: string): Promise { - return this.get(`${workspaceId}/users`) + return this.get(`workspaces/${workspaceId}/users`) } /** @@ -172,21 +212,41 @@ class SlackApiClient extends ApiClient { workspaceId: string, userId: string ): Promise { - return this.get(`${workspaceId}/users/${userId}`) + return this.get(`workspaces/${workspaceId}/users/${userId}`) } /** * Run channel analysis + * @param workspaceId Database UUID for the workspace + * @param channelId Database UUID for the channel + * @param analysisType Type of analysis to run (e.g., 'contribution') + * @param options Optional parameters for the analysis */ async analyzeChannel( workspaceId: string, channelId: string, - analysisType: string + analysisType: string, + options?: { + start_date?: string + end_date?: string + include_threads?: boolean + include_reactions?: boolean + model?: string + } ): Promise { - return this.post( - `${workspaceId}/channels/${channelId}/analyze`, - { analysis_type: analysisType } - ) + const data = { + analysis_type: analysisType, + ...options, + } + + // Build path with workspaceId (database UUID) and channelId (database UUID) + // Make sure to use a leading slash so it builds the URL correctly + const path = `/workspaces/${workspaceId}/channels/${channelId}/analyze` + + // Log the full URL that will be constructed + console.log(`Making API call to: ${this.apiBaseUrl}${path}`) + + return this.post(path, data) } /** @@ -196,9 +256,10 @@ class SlackApiClient extends ApiClient { workspaceId: string, channelId: string ): Promise { - return this.get( - `${workspaceId}/channels/${channelId}/analysis` - ) + // Be consistent with leading slash + const path = `/workspaces/${workspaceId}/channels/${channelId}/analyses` + console.log(`Getting analysis history from: ${this.apiBaseUrl}${path}`) + return this.get(path) } /** @@ -207,7 +268,7 @@ class SlackApiClient extends ApiClient { async syncWorkspace( workspaceId: string ): Promise | ApiError> { - return this.post>(`${workspaceId}/sync`) + return this.post>(`workspaces/${workspaceId}/sync`) } } diff --git a/frontend/src/pages/integration/TeamAnalysisResultPage.tsx b/frontend/src/pages/integration/TeamAnalysisResultPage.tsx new file mode 100644 index 00000000..b72f76f6 --- /dev/null +++ b/frontend/src/pages/integration/TeamAnalysisResultPage.tsx @@ -0,0 +1,434 @@ +import React, { useEffect, useState } from 'react' +import { + Box, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + Button, + Flex, + Heading, + Icon, + Spinner, + Text, + useToast, + Card, + CardHeader, + CardBody, + HStack, + Tabs, + TabList, + Tab, + TabPanels, + TabPanel, + SimpleGrid, + Badge, + Stat, + StatLabel, + StatNumber, +} from '@chakra-ui/react' +import { FiChevronRight, FiArrowLeft, FiClock } from 'react-icons/fi' +import { Link, useParams, useNavigate } from 'react-router-dom' +import MessageText from '../../components/slack/MessageText' +import { SlackUserCacheProvider } from '../../components/slack/SlackUserContext' +import useIntegration from '../../context/useIntegration' +import integrationService, { + ServiceResource, +} from '../../lib/integrationService' + +interface AnalysisResponse { + id: string + channel_id: string + channel_name: string + start_date: string + end_date: string + message_count: number + participant_count: number + thread_count: number + reaction_count: number + channel_summary: string + topic_analysis: string + contributor_insights: string + key_highlights: string + model_used: string + generated_at: string +} + +interface Channel extends ServiceResource { + type: string + topic?: string + purpose?: string +} + +/** + * Page component for viewing a specific analysis result. + */ +const TeamAnalysisResultPage: React.FC = () => { + const { integrationId, channelId, analysisId } = useParams<{ + integrationId: string + channelId: string + analysisId: string + }>() + const navigate = useNavigate() + const toast = useToast() + const { + currentResources, + currentIntegration, + fetchIntegration, + fetchResources, + } = useIntegration() + + const [channel, setChannel] = useState(null) + const [analysis, setAnalysis] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [currentTab, setCurrentTab] = useState(0) + + // Format date for display + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleString() + } + + useEffect(() => { + if (integrationId && channelId && analysisId) { + fetchData() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [integrationId, channelId, analysisId]) + + const fetchData = async () => { + try { + setIsLoading(true) + + // Fetch integration info + if (integrationId) { + await fetchIntegration(integrationId) + } + + // Fetch channel from resource list + if (integrationId && channelId) { + await fetchResources(integrationId) + const channelResource = currentResources.find( + (resource) => resource.id === channelId + ) + if (channelResource) { + setChannel(channelResource as Channel) + } + } + + // Fetch the specific analysis using the integration service + console.log( + `Fetching analysis ${analysisId} for integration ${integrationId} and channel ${channelId}` + ) + const analysisResult = await integrationService.getResourceAnalysis( + integrationId || '', + channelId || '', + analysisId || '' + ) + + // Check if the result is an API error + if (integrationService.isApiError(analysisResult)) { + throw new Error(`Error fetching analysis: ${analysisResult.message}`) + } + + // Set the analysis data, casting it to the expected type + // This is safe because we've verified it's not an ApiError above + setAnalysis(analysisResult as unknown as AnalysisResponse) + } catch (error) { + console.error('Error fetching data:', error) + toast({ + title: 'Error', + description: 'Failed to load analysis data', + status: 'error', + duration: 5000, + isClosable: true, + }) + } finally { + setIsLoading(false) + } + } + + const renderAnalysisContent = () => { + if (!analysis) { + return ( + + Analysis data could not be loaded or is not available. + + ) + } + + return ( + + + Summary + Topics + Contributors + Highlights + + + + {/* Summary Panel */} + + + + Channel Summary + + + {analysis.channel_summary + .split('\n') + .map((paragraph, index) => ( + + {paragraph.trim() ? ( + + ) : ( + + )} + + ))} + + + + + Messages + {analysis.message_count} + + + Participants + {analysis.participant_count} + + + Threads + {analysis.thread_count} + + + Reactions + {analysis.reaction_count} + + + + + + {/* Topics Panel */} + + + + Topic Analysis + + + {analysis.topic_analysis.split('\n').map((paragraph, index) => ( + + {paragraph.trim() ? ( + + ) : ( + + )} + + ))} + + + + + {/* Contributors Panel */} + + + + Contributor Insights + + + {analysis.contributor_insights + .split('\n') + .map((paragraph, index) => ( + + {paragraph.trim() ? ( + + ) : ( + + )} + + ))} + + + + + {/* Highlights Panel */} + + + + Key Highlights + + + {analysis.key_highlights.split('\n').map((paragraph, index) => ( + + {paragraph.trim() ? ( + + ) : ( + + )} + + ))} + + + + + + ) + } + + return ( + + + {/* Breadcrumb navigation */} + } + mb={6} + > + + + Dashboard + + + + + Integrations + + + + + {currentIntegration?.name || 'Integration'} + + + + + Channels + + + + + Analysis + + + + Results + + + + {/* Header actions */} + + + + + + + {isLoading ? ( + + + + ) : ( + <> + + + Analysis Results + + + {currentIntegration?.name} + > + + {channel?.name + ? channel.name.startsWith('#') + ? channel.name + : `#${channel.name}` + : analysis?.channel_name + ? analysis.channel_name.startsWith('#') + ? analysis.channel_name + : `#${analysis.channel_name}` + : '#channel'} + + + {channel?.type || 'channel'} + + + + {analysis && ( + + Analyzed period:{' '} + {new Date(analysis.start_date).toLocaleDateString()} to{' '} + {new Date(analysis.end_date).toLocaleDateString()} + + )} + + + + + Analysis Results + + Generated on{' '} + {analysis ? formatDate(analysis.generated_at) : ''} + + + {renderAnalysisContent()} + + + )} + + + ) +} + +export default TeamAnalysisResultPage diff --git a/frontend/src/pages/integration/TeamChannelAnalysisHistoryPage.tsx b/frontend/src/pages/integration/TeamChannelAnalysisHistoryPage.tsx new file mode 100644 index 00000000..0126adeb --- /dev/null +++ b/frontend/src/pages/integration/TeamChannelAnalysisHistoryPage.tsx @@ -0,0 +1,294 @@ +import React, { useEffect, useState } from 'react' +import { + Box, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + Button, + Flex, + Heading, + Icon, + Spinner, + Text, + useToast, + Card, + CardHeader, + CardBody, + HStack, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + Badge, +} from '@chakra-ui/react' +import { + FiChevronRight, + FiArrowLeft, + FiClock, + FiFileText, +} from 'react-icons/fi' +import { Link, useParams, useNavigate } from 'react-router-dom' +import useIntegration from '../../context/useIntegration' +import integrationService, { + ServiceResource, +} from '../../lib/integrationService' + +interface AnalysisHistoryItem { + id: string + channel_id: string + start_date: string + end_date: string + message_count: number + model_used: string + generated_at: string +} + +interface Channel extends ServiceResource { + type: string + topic?: string + purpose?: string +} + +const TeamChannelAnalysisHistoryPage: React.FC = () => { + const { integrationId, channelId } = useParams<{ + integrationId: string + channelId: string + }>() + const navigate = useNavigate() + const toast = useToast() + const { + currentResources, + currentIntegration, + fetchIntegration, + fetchResources, + } = useIntegration() + + const [channel, setChannel] = useState(null) + const [analyses, setAnalyses] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + // Format date for display + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleString() + } + + useEffect(() => { + if (integrationId && channelId) { + fetchData() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [integrationId, channelId]) + + const fetchData = async () => { + try { + setIsLoading(true) + + // Fetch integration info + if (integrationId) { + await fetchIntegration(integrationId) + } + + // Fetch channel from resource list + if (integrationId && channelId) { + await fetchResources(integrationId) + const channelResource = currentResources.find( + (resource) => resource.id === channelId + ) + if (channelResource) { + setChannel(channelResource as Channel) + } + } + + // Fetch analysis history using integrationService + const analysesResult = await integrationService.getResourceAnalyses( + integrationId || '', + channelId || '' + ) + + // Check if the result is an API error + if (integrationService.isApiError(analysesResult)) { + throw new Error( + `Error fetching analysis history: ${analysesResult.message}` + ) + } + + // Set the analyses from the fetched result, casting the result to the expected type + // This is safe because we're checking that the result is not an ApiError above + setAnalyses(analysesResult as unknown as AnalysisHistoryItem[]) + } catch (error) { + console.error('Error fetching data:', error) + toast({ + title: 'Error', + description: 'Failed to load analysis history', + status: 'error', + duration: 5000, + isClosable: true, + }) + } finally { + setIsLoading(false) + } + } + + const renderHistoryTable = () => { + return ( + + + + + + + + + + + + + {analyses.map((analysis) => ( + + + + + + + + ))} + +
DatePeriodMessagesModelAction
{formatDate(analysis.generated_at)} + {new Date(analysis.start_date).toLocaleDateString()} -{' '} + {new Date(analysis.end_date).toLocaleDateString()} + {analysis.message_count} + + {analysis.model_used?.split('/').pop() || 'AI Model'} + + + +
+
+ ) + } + + return ( + + {/* Breadcrumb navigation */} + } + mb={6} + > + + + Dashboard + + + + + Integrations + + + + + {currentIntegration?.name || 'Integration'} + + + + + Channels + + + + Analysis History + + + + {/* Header actions */} + + + {channel?.name + ? `#${channel.name} Analysis History` + : 'Channel Analysis History'} + + + + + + + + {isLoading ? ( + + + + ) : analyses.length === 0 ? ( + + + + + + No Analysis History + + + There are no saved analyses for this channel yet. + + + + + + ) : ( + + + Analysis History + + {renderHistoryTable()} + + )} + + ) +} + +export default TeamChannelAnalysisHistoryPage diff --git a/frontend/src/pages/integration/TeamChannelAnalysisPage.tsx b/frontend/src/pages/integration/TeamChannelAnalysisPage.tsx new file mode 100644 index 00000000..194f6d94 --- /dev/null +++ b/frontend/src/pages/integration/TeamChannelAnalysisPage.tsx @@ -0,0 +1,791 @@ +import React, { useEffect, useState } from 'react' +import { + Box, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + Button, + Flex, + Heading, + Icon, + Spinner, + Text, + useToast, + Card, + CardHeader, + CardBody, + FormControl, + FormLabel, + Input, + HStack, + VStack, + Divider, + SimpleGrid, + Badge, + Stat, + StatLabel, + StatNumber, + StatHelpText, + Switch, + FormHelperText, + Alert, + AlertIcon, + AlertTitle, + AlertDescription, +} from '@chakra-ui/react' +import { FiChevronRight, FiArrowLeft, FiRefreshCw } from 'react-icons/fi' +import { Link, useParams, useNavigate } from 'react-router-dom' +import env from '../../config/env' +import { SlackUserCacheProvider } from '../../components/slack/SlackUserContext' +import MessageText from '../../components/slack/MessageText' +import useIntegration from '../../context/useIntegration' +import integrationService, { + IntegrationType, + ServiceResource, +} from '../../lib/integrationService' +import { SlackAnalysisResult } from '../../lib/slackApiClient' + +// Use the SlackAnalysisResult interface directly from slackApiClient.ts +type AnalysisResponse = SlackAnalysisResult + +interface Channel extends ServiceResource { + type: string + topic?: string + purpose?: string + workspace_uuid?: string + channel_uuid?: string + external_resource_id?: string +} + +/** + * Page component for analyzing a channel from a team integration and displaying results. + */ +const TeamChannelAnalysisPage: React.FC = () => { + const { integrationId, channelId } = useParams<{ + integrationId: string + channelId: string + }>() + const [channel, setChannel] = useState(null) + const [analysis, setAnalysis] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isChannelLoading, setIsChannelLoading] = useState(true) + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const [includeThreads, setIncludeThreads] = useState(true) + const [includeReactions, setIncludeReactions] = useState(true) + const toast = useToast() + const navigate = useNavigate() + + const { currentIntegration, fetchIntegration } = useIntegration() + + // Format date for display + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleDateString() + } + + // Format date for input fields + const formatDateForInput = (date: Date) => { + return date.toISOString().split('T')[0] + } + + // Set default date range (last 30 days) + useEffect(() => { + const end = new Date() + const start = new Date() + start.setDate(start.getDate() - 30) + + setStartDate(formatDateForInput(start)) + setEndDate(formatDateForInput(end)) + }, []) + + // Fetch integration first to ensure it's loaded + useEffect(() => { + if (integrationId) { + console.log('Loading integration:', integrationId) + fetchIntegration(integrationId) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [integrationId]) + + // Once integration is loaded, fetch channel info + useEffect(() => { + if (integrationId && channelId && currentIntegration) { + console.log( + 'Integration loaded, fetching channel:', + currentIntegration.id + ) + // Only fetch if we don't already have the channel + if (!channel) { + fetchIntegrationAndChannel() + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [integrationId, channelId, currentIntegration]) + + /** + * Fetch channel information only - integration must already be loaded. + */ + const fetchIntegrationAndChannel = async () => { + try { + setIsChannelLoading(true) + + // Verify we have the integration and channel IDs + if (!currentIntegration) { + console.log('Integration not loaded yet - skipping channel fetch') + return // Just return and wait for the next render when integration is loaded + } + + if (!channelId) { + console.error('Missing channel ID') + return + } + + console.log( + 'Fetching channel data using integration:', + currentIntegration.id + ) + + // Get channel via the integration service + const channelData = await integrationService.getResource( + integrationId || '', + channelId + ) + + // Check if the result is an API error + if (integrationService.isApiError(channelData)) { + throw new Error(`Failed to fetch channel: ${channelData.message}`) + } + + console.log('Channel data retrieved successfully:', channelData.name) + + // Create enriched channel data with proper IDs for the API + const enrichedChannel: Channel = { + ...channelData, + // Store the database UUIDs for API calls + workspace_uuid: currentIntegration.id, // Database UUID for the workspace + channel_uuid: channelData.id, // Database UUID for the channel + external_id: currentIntegration.workspace_id || '', // Slack workspace ID + external_resource_id: channelData.external_id, // Slack channel ID + type: + channelData.metadata?.type || channelData.metadata?.is_private + ? channelData.metadata?.is_private + ? 'private' + : 'public' + : 'public', + topic: + typeof channelData.metadata?.topic === 'string' + ? channelData.metadata.topic + : '', + purpose: + typeof channelData.metadata?.purpose === 'string' + ? channelData.metadata.purpose + : '', + } + + setChannel(enrichedChannel) + } catch (error) { + console.error('Error fetching channel:', error) + toast({ + title: 'Error', + description: + error instanceof Error + ? error.message + : 'Failed to load channel information', + status: 'error', + duration: 5000, + isClosable: true, + }) + } finally { + setIsChannelLoading(false) + } + } + + /** + * Run channel analysis with current settings. + */ + const runAnalysis = async () => { + if (!integrationId || !channelId) { + toast({ + title: 'Error', + description: 'Missing integration or channel ID', + status: 'error', + duration: 5000, + isClosable: true, + }) + return + } + + try { + setIsLoading(true) + setAnalysis(null) + + // Format date parameters + const startDateParam = startDate ? new Date(startDate).toISOString() : '' + const endDateParam = endDate ? new Date(endDate).toISOString() : '' + + // Verify we have channel data with UUIDs + if (!channel || !channel.workspace_uuid || !channel.channel_uuid) { + throw new Error('Channel data with database UUIDs is required') + } + + // Log the actual request parameters we're going to use + console.log('Analyzing channel with parameters:') + console.log('- workspace_uuid:', channel.workspace_uuid) + console.log('- channel_uuid:', channel.channel_uuid) + console.log('- start_date:', startDateParam || 'undefined') + console.log('- end_date:', endDateParam || 'undefined') + + // Log the actual request parameters we're going to use + console.log('Analyzing channel with parameters:') + console.log('- integrationId:', integrationId) + console.log('- channelId (resource UUID):', channelId) + console.log('- channel UUID from data:', channel?.id) + console.log('- start_date:', startDateParam || 'undefined') + console.log('- end_date:', endDateParam || 'undefined') + + // First - sync the Slack data to ensure we have the latest messages + toast({ + title: 'Syncing channel data', + description: + 'Fetching the latest messages from Slack before analysis...', + status: 'info', + duration: 5000, + isClosable: true, + }) + + try { + // Step 1: Sync general integration resources (channels, users, etc.) + console.log('Syncing general integration data first...') + const syncResult = await integrationService.syncResources( + integrationId || '' + ) + + // Step 2: Specifically sync messages for this channel + console.log(`Syncing messages specifically for channel ${channelId}...`) + const syncChannelEndpoint = `${env.apiUrl}/integrations/${integrationId}/resources/${channelId}/sync-messages` + + // Calculate a reasonable date range (use the analysis date range if specified, or last 90 days) + const startDateParam = startDate + ? new Date(startDate).toISOString() + : new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString() + const endDateParam = endDate + ? new Date(endDate).toISOString() + : new Date().toISOString() + + // Build the request URL with query parameters + const url = new URL(syncChannelEndpoint) + url.searchParams.append('start_date', startDateParam) + url.searchParams.append('end_date', endDateParam) + url.searchParams.append('include_replies', includeThreads.toString()) + + // Make the channel messages sync request + const headers = await integrationService.getAuthHeaders() + + let channelSyncResponse + try { + channelSyncResponse = await fetch(url.toString(), { + method: 'POST', + headers, + credentials: 'include', + }) + } catch (error) { + console.error('Fetch error in channel sync:', error) + throw error + } + + if (!channelSyncResponse.ok) { + // Try to get more detailed error information + let errorDetail = '' + try { + const responseText = await channelSyncResponse.text() + try { + const errorData = JSON.parse(responseText) + errorDetail = + errorData.detail || errorData.message || responseText + } catch { + errorDetail = responseText || channelSyncResponse.statusText + } + } catch { + // Ignore response reading errors + } + + toast({ + title: 'Channel Sync Warning', + description: `Channel messages sync was not fully successful: ${errorDetail || channelSyncResponse.statusText}. Analysis may not include the latest messages.`, + status: 'warning', + duration: 7000, + isClosable: true, + }) + } else { + const channelSyncResult = await channelSyncResponse.json() + console.log('Channel messages sync successful:', channelSyncResult) + + // Extract sync statistics from the response + const syncStats = channelSyncResult.sync_results || {} + const newMessages = syncStats.new_message_count || 0 + const repliesCount = syncStats.replies_synced || 0 + + toast({ + title: 'Channel Sync Complete', + description: `Synced ${newMessages} new messages and ${repliesCount} thread replies from Slack.`, + status: 'success', + duration: 3000, + isClosable: true, + }) + } + + // Check if general sync was successful + if (integrationService.isApiError(syncResult)) { + console.warn('General sync warning:', syncResult.message) + toast({ + title: 'General Sync Warning', + description: + 'General resource sync was not fully successful, but channel messages were synced.', + status: 'warning', + duration: 5000, + isClosable: true, + }) + } else { + console.log('General sync successful:', syncResult) + } + } catch (syncError) { + console.error('Error syncing data:', syncError) + toast({ + title: 'Sync Error', + description: + syncError instanceof Error + ? `Failed to sync channel data: ${syncError.message}. Analysis will use existing data.` + : 'Failed to sync channel data. Analysis will use existing data.', + status: 'warning', + duration: 7000, + isClosable: true, + }) + } + + // Now, use integrationService to analyze the resource + const result = await integrationService.analyzeResource( + integrationId || '', // Integration UUID + channelId || '', // Resource UUID (which should be the same as channel.id) + { + analysis_type: 'contribution', + start_date: startDateParam || undefined, + end_date: endDateParam || undefined, + include_threads: includeThreads, + include_reactions: includeReactions, + } + ) + + // Check if the result is an error + if (integrationService.isApiError(result)) { + const errorMessage = `Analysis request failed: ${result.message}${result.detail ? `\nDetail: ${result.detail}` : ''}` + console.error(errorMessage) + throw new Error(errorMessage) + } + + // Set the analysis result, casting to the expected type + // This is safe because we've verified it's not an ApiError above + setAnalysis(result as unknown as SlackAnalysisResult) + + toast({ + title: 'Analysis Complete', + description: 'Channel analysis has been completed successfully', + status: 'success', + duration: 5000, + isClosable: true, + }) + + // Navigate to the analysis result page + if (result.analysis_id) { + navigate( + `/dashboard/integrations/${integrationId}/channels/${channelId}/analysis/${result.analysis_id}` + ) + } + } catch (error) { + console.error('Error during analysis:', error) + + // Show a more detailed error message with actionable information + toast({ + title: 'Analysis Failed', + description: + error instanceof Error ? error.message : 'Failed to analyze channel', + status: 'error', + duration: 10000, + isClosable: true, + }) + + // If needed, show details about implementation status + toast({ + title: 'API Information', + description: + 'Using the newly implemented team-based channel analysis API endpoint. ' + + 'If you encounter issues, please report them.', + status: 'info', + duration: 10000, + isClosable: true, + }) + } finally { + setIsLoading(false) + } + } + + /** + * Format text with paragraphs and process Slack mentions. + */ + const formatText = (text: string | undefined) => { + if (!text) return No data available + + return text.split('\n').map((paragraph, index) => ( + + {paragraph.trim() ? ( + + ) : ( + + )} + + )) + } + + /** + * Render analysis parameters form. + */ + const renderAnalysisForm = () => { + return ( + + + Analysis Parameters + + + + + + + + + Start Date + setStartDate(e.target.value)} + /> + + + + End Date + setEndDate(e.target.value)} + /> + + + + + + setIncludeThreads(e.target.checked)} + colorScheme="purple" + mr={2} + /> + + Include Thread Replies + + + + + setIncludeReactions(e.target.checked)} + colorScheme="purple" + mr={2} + /> + + Include Reactions + + + + + + + Select a date range and options for analysis. A larger date + range will take longer to analyze. + + + + + + + + + + ) + } + + /** + * Render analysis results. + */ + const renderAnalysisResults = () => { + if (!analysis) return null + + return ( + + + Analysis Results + + + + + Messages + {analysis.stats?.message_count || 0} + Total messages analyzed + + + + Participants + {analysis.stats?.participant_count || 0} + Unique contributors + + + + Threads + {analysis.stats?.thread_count || 0} + Conversation threads + + + + Reactions + {analysis.stats?.reaction_count || 0} + Total emoji reactions + + + + + + + Channel Summary + + {formatText(analysis.channel_summary)} + + + + + Topic Analysis + + {formatText(analysis.topic_analysis)} + + + + + Contributor Insights + + {formatText(analysis.contributor_insights)} + + + + + Key Highlights + + {formatText(analysis.key_highlights)} + + + + + + + Analysis period: + + + {analysis.period?.start + ? formatDate(analysis.period.start) + : 'Unknown'}{' '} + to{' '} + {analysis.period?.end + ? formatDate(analysis.period.end) + : 'Unknown'} + + + + + + Model: + + {analysis.model_used || 'Unknown'} + + + + + Generated: + + + {analysis.generated_at + ? new Date(analysis.generated_at).toLocaleString() + : 'Unknown'} + + + + + ) + } + + if (!integrationId || !currentIntegration) { + return ( + + Integration not found + + + ) + } + + // Show incompatible integration type + if (currentIntegration.service_type !== IntegrationType.SLACK) { + return ( + + + + + + Unsupported integration type + + Channel analysis is currently only available for Slack integrations. + + + + ) + } + + return ( + + + {/* Breadcrumb navigation */} + } + mb={6} + > + + + Dashboard + + + + + Integrations + + + + + {currentIntegration.name} + + + + + Channels + + + + Channel Analysis + + + + {/* Back button */} + + + {isChannelLoading ? ( + + + + ) : ( + <> + + + Channel Analysis + + + {currentIntegration.name} + > + #{channel?.name} + + {channel?.type} + + + {channel?.topic && ( + + Topic: {channel.topic} + + )} + + + {/* Analysis form */} + {renderAnalysisForm()} + + {/* Loading state */} + {isLoading && ( + + + + Analyzing channel messages... This may take a minute. + + + )} + + {/* Analysis results */} + {analysis && renderAnalysisResults()} + + )} + + + ) +} + +export default TeamChannelAnalysisPage diff --git a/frontend/src/pages/integration/index.ts b/frontend/src/pages/integration/index.ts index e6e2fa04..f2e6324d 100644 --- a/frontend/src/pages/integration/index.ts +++ b/frontend/src/pages/integration/index.ts @@ -2,3 +2,6 @@ export { default as IntegrationsPage } from './IntegrationsPage' export { default as IntegrationDetailPage } from './IntegrationDetailPage' export { default as IntegrationConnectPage } from './IntegrationConnectPage' export { default as TeamChannelSelectorPage } from './TeamChannelSelectorPage' +export { default as TeamChannelAnalysisPage } from './TeamChannelAnalysisPage' +export { default as TeamAnalysisResultPage } from './TeamAnalysisResultPage' +export { default as TeamChannelAnalysisHistoryPage } from './TeamChannelAnalysisHistoryPage'