diff --git a/backend/igny8_core/api/base.py b/backend/igny8_core/api/base.py index 864ad78d..5adef3cf 100644 --- a/backend/igny8_core/api/base.py +++ b/backend/igny8_core/api/base.py @@ -1,9 +1,12 @@ """ Base ViewSet with account filtering support +Unified API Standard v1.0 compliant """ -from rest_framework import viewsets +from rest_framework import viewsets, status from rest_framework.response import Response +from rest_framework.exceptions import ValidationError as DRFValidationError from django.core.exceptions import PermissionDenied +from .response import success_response, error_response class AccountModelViewSet(viewsets.ModelViewSet): @@ -74,6 +77,123 @@ class AccountModelViewSet(viewsets.ModelViewSet): if account: context['account'] = account return context + + def retrieve(self, request, *args, **kwargs): + """ + Override retrieve to return unified format + """ + try: + instance = self.get_object() + serializer = self.get_serializer(instance) + return success_response(data=serializer.data, request=request) + except Exception as e: + return error_response( + error=str(e), + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + + def create(self, request, *args, **kwargs): + """ + Override create to return unified format + """ + serializer = self.get_serializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return success_response( + data=serializer.data, + message='Created successfully', + request=request, + status_code=status.HTTP_201_CREATED + ) + except DRFValidationError as e: + return error_response( + error='Validation error', + errors=e.detail if hasattr(e, 'detail') else str(e), + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error in create method: {str(e)}", exc_info=True) + # Check if it's a validation-related error + if 'required' in str(e).lower() or 'invalid' in str(e).lower() or 'validation' in str(e).lower(): + return error_response( + error='Validation error', + errors=str(e), + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + # For other errors, return 500 + return error_response( + error=f'Internal server error: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + def update(self, request, *args, **kwargs): + """ + Override update to return unified format + """ + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + try: + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return success_response( + data=serializer.data, + message='Updated successfully', + request=request + ) + except DRFValidationError as e: + return error_response( + error='Validation error', + errors=e.detail if hasattr(e, 'detail') else str(e), + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error in create method: {str(e)}", exc_info=True) + # Check if it's a validation-related error + if 'required' in str(e).lower() or 'invalid' in str(e).lower() or 'validation' in str(e).lower(): + return error_response( + error='Validation error', + errors=str(e), + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + # For other errors, return 500 + return error_response( + error=f'Internal server error: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + def destroy(self, request, *args, **kwargs): + """ + Override destroy to return unified format + """ + try: + instance = self.get_object() + self.perform_destroy(instance) + return success_response( + data=None, + message='Deleted successfully', + request=request, + status_code=status.HTTP_204_NO_CONTENT + ) + except Exception as e: + return error_response( + error=str(e), + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) class SiteSectorModelViewSet(AccountModelViewSet): diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 3e09a436..446cb610 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -119,7 +119,7 @@ class GroupsViewSet(viewsets.ViewSet): # 2. USERS - Manage global user records and credentials # ============================================================================ -class UsersViewSet(viewsets.ModelViewSet): +class UsersViewSet(AccountModelViewSet): """ ViewSet for managing global user records and credentials. Users are global, but belong to accounts. @@ -246,13 +246,17 @@ class UsersViewSet(viewsets.ModelViewSet): # 3. ACCOUNTS - Register each unique organization/user space # ============================================================================ -class AccountsViewSet(viewsets.ModelViewSet): +class AccountsViewSet(AccountModelViewSet): """ ViewSet for managing accounts (unique organization/user spaces). + Unified API Standard v1.0 compliant """ queryset = Account.objects.all() serializer_class = AccountSerializer permission_classes = [IsOwnerOrAdmin] + pagination_class = CustomPageNumberPagination + throttle_scope = 'auth' + throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Return accounts based on access level.""" @@ -299,12 +303,16 @@ class AccountsViewSet(viewsets.ModelViewSet): # 4. SUBSCRIPTIONS - Control plan level, limits, and billing per account # ============================================================================ -class SubscriptionsViewSet(viewsets.ModelViewSet): +class SubscriptionsViewSet(AccountModelViewSet): """ ViewSet for managing subscriptions (plan level, limits, billing per account). + Unified API Standard v1.0 compliant """ queryset = Subscription.objects.all() permission_classes = [IsOwnerOrAdmin] + pagination_class = CustomPageNumberPagination + throttle_scope = 'auth' + throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Return subscriptions based on access level.""" @@ -348,13 +356,17 @@ class SubscriptionsViewSet(viewsets.ModelViewSet): # 5. SITE USER ACCESS - Assign users access to specific sites within account # ============================================================================ -class SiteUserAccessViewSet(viewsets.ModelViewSet): +class SiteUserAccessViewSet(AccountModelViewSet): """ ViewSet for managing Site-User access permissions. Assign users access to specific sites within their account. + Unified API Standard v1.0 compliant """ serializer_class = SiteUserAccessSerializer permission_classes = [IsOwnerOrAdmin] + pagination_class = CustomPageNumberPagination + throttle_scope = 'auth' + throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Return access records for sites in user's account.""" @@ -383,10 +395,29 @@ class SiteUserAccessViewSet(viewsets.ModelViewSet): # ============================================================================ class PlanViewSet(viewsets.ReadOnlyModelViewSet): - """ViewSet for listing active subscription plans.""" + """ + ViewSet for listing active subscription plans. + Unified API Standard v1.0 compliant + """ queryset = Plan.objects.filter(is_active=True) serializer_class = PlanSerializer permission_classes = [permissions.AllowAny] + pagination_class = CustomPageNumberPagination + throttle_scope = 'auth' + throttle_classes = [DebugScopedRateThrottle] + + def retrieve(self, request, *args, **kwargs): + """Override retrieve to return unified format""" + try: + instance = self.get_object() + serializer = self.get_serializer(instance) + return success_response(data=serializer.data, request=request) + except Exception as e: + return error_response( + error=str(e), + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) class SiteViewSet(AccountModelViewSet): @@ -662,10 +693,16 @@ class SectorViewSet(AccountModelViewSet): class IndustryViewSet(viewsets.ReadOnlyModelViewSet): - """ViewSet for industry templates.""" + """ + ViewSet for industry templates. + Unified API Standard v1.0 compliant + """ queryset = Industry.objects.filter(is_active=True).prefetch_related('sectors') serializer_class = IndustrySerializer permission_classes = [permissions.AllowAny] + pagination_class = CustomPageNumberPagination + throttle_scope = 'auth' + throttle_classes = [DebugScopedRateThrottle] def list(self, request): """Get all industries with their sectors.""" @@ -675,13 +712,32 @@ class IndustryViewSet(viewsets.ReadOnlyModelViewSet): data={'industries': serializer.data}, request=request ) + + def retrieve(self, request, *args, **kwargs): + """Override retrieve to return unified format""" + try: + instance = self.get_object() + serializer = self.get_serializer(instance) + return success_response(data=serializer.data, request=request) + except Exception as e: + return error_response( + error=str(e), + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): - """ViewSet for SeedKeyword - Global reference data (read-only for non-admins).""" + """ + ViewSet for SeedKeyword - Global reference data (read-only for non-admins). + Unified API Standard v1.0 compliant + """ queryset = SeedKeyword.objects.filter(is_active=True).select_related('industry', 'sector') serializer_class = SeedKeywordSerializer permission_classes = [permissions.AllowAny] # Read-only, allow any authenticated user + pagination_class = CustomPageNumberPagination + throttle_scope = 'auth' + throttle_classes = [DebugScopedRateThrottle] filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend] search_fields = ['keyword'] @@ -689,6 +745,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): ordering = ['keyword'] filterset_fields = ['industry', 'sector', 'intent', 'is_active'] + def retrieve(self, request, *args, **kwargs): + """Override retrieve to return unified format""" + try: + instance = self.get_object() + serializer = self.get_serializer(instance) + return success_response(data=serializer.data, request=request) + except Exception as e: + return error_response( + error=str(e), + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + def get_queryset(self): """Filter by industry and sector if provided.""" queryset = super().get_queryset() diff --git a/backend/igny8_core/modules/planner/serializers.py b/backend/igny8_core/modules/planner/serializers.py index 6525afaa..c63e4701 100644 --- a/backend/igny8_core/modules/planner/serializers.py +++ b/backend/igny8_core/modules/planner/serializers.py @@ -13,7 +13,8 @@ class KeywordSerializer(serializers.ModelSerializer): intent = serializers.CharField(read_only=True) # From seed_keyword.intent # SeedKeyword relationship - seed_keyword_id = serializers.IntegerField(write_only=True, required=True) + # Required for create, optional for update (can change seed_keyword or just update other fields) + seed_keyword_id = serializers.IntegerField(write_only=True, required=False) seed_keyword = SeedKeywordSerializer(read_only=True) # Overrides @@ -50,9 +51,19 @@ class KeywordSerializer(serializers.ModelSerializer): ] read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'keyword', 'volume', 'difficulty', 'intent'] + def validate(self, attrs): + """Validate that seed_keyword_id is provided for create operations""" + # For create operations, seed_keyword_id is required + if self.instance is None and 'seed_keyword_id' not in attrs: + raise serializers.ValidationError({'seed_keyword_id': 'This field is required when creating a keyword.'}) + return attrs + def create(self, validated_data): """Create Keywords instance with seed_keyword""" - seed_keyword_id = validated_data.pop('seed_keyword_id') + seed_keyword_id = validated_data.pop('seed_keyword_id', None) + if not seed_keyword_id: + raise serializers.ValidationError({'seed_keyword_id': 'This field is required when creating a keyword.'}) + try: seed_keyword = SeedKeyword.objects.get(id=seed_keyword_id) except SeedKeyword.DoesNotExist: @@ -63,6 +74,7 @@ class KeywordSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): """Update Keywords instance with seed_keyword""" + # seed_keyword_id is optional for updates - only update if provided if 'seed_keyword_id' in validated_data: seed_keyword_id = validated_data.pop('seed_keyword_id') try: diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 1c88a0e6..88c26e10 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -1,12 +1,16 @@ """ ViewSets for Settings Models +Unified API Standard v1.0 compliant """ from rest_framework import viewsets, status, permissions from rest_framework.decorators import action from rest_framework.response import Response from django.db import transaction from igny8_core.api.base import AccountModelViewSet +from igny8_core.api.response import success_response, error_response from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication +from igny8_core.api.pagination import CustomPageNumberPagination +from igny8_core.api.throttles import DebugScopedRateThrottle from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings from .settings_serializers import ( SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer, @@ -14,14 +18,18 @@ from .settings_serializers import ( ) -class SystemSettingsViewSet(viewsets.ModelViewSet): +class SystemSettingsViewSet(AccountModelViewSet): """ ViewSet for managing system-wide settings (admin only for write operations) + Unified API Standard v1.0 compliant """ queryset = SystemSettings.objects.all() serializer_class = SystemSettingsSerializer permission_classes = [permissions.IsAuthenticated] # Require authentication authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] + pagination_class = CustomPageNumberPagination + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] def get_permissions(self): """Admin only for write operations, read for authenticated users""" @@ -43,23 +51,28 @@ class SystemSettingsViewSet(viewsets.ModelViewSet): try: setting = SystemSettings.objects.get(key=pk) except SystemSettings.DoesNotExist: - return Response( - {'error': 'Setting not found'}, - status=status.HTTP_404_NOT_FOUND + return error_response( + error='Setting not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request ) serializer = self.get_serializer(setting) - return Response(serializer.data) + return success_response(data=serializer.data, request=request) class AccountSettingsViewSet(AccountModelViewSet): """ ViewSet for managing account-level settings + Unified API Standard v1.0 compliant """ queryset = AccountSettings.objects.all() serializer_class = AccountSettingsSerializer permission_classes = [permissions.IsAuthenticated] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] + pagination_class = CustomPageNumberPagination + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Get settings for current account""" @@ -76,13 +89,14 @@ class AccountSettingsViewSet(AccountModelViewSet): try: setting = queryset.get(key=pk) except AccountSettings.DoesNotExist: - return Response( - {'error': 'Setting not found'}, - status=status.HTTP_404_NOT_FOUND + return error_response( + error='Setting not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request ) serializer = self.get_serializer(setting) - return Response(serializer.data) + return success_response(data=serializer.data, request=request) def perform_create(self, serializer): """Set account automatically""" @@ -99,14 +113,18 @@ class AccountSettingsViewSet(AccountModelViewSet): serializer.save(account=account) -class UserSettingsViewSet(viewsets.ModelViewSet): +class UserSettingsViewSet(AccountModelViewSet): """ ViewSet for managing user-level settings + Unified API Standard v1.0 compliant """ queryset = UserSettings.objects.all() serializer_class = UserSettingsSerializer permission_classes = [permissions.IsAuthenticated] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] + pagination_class = CustomPageNumberPagination + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Get settings for current user and account""" @@ -130,13 +148,14 @@ class UserSettingsViewSet(viewsets.ModelViewSet): try: setting = queryset.get(key=pk) except UserSettings.DoesNotExist: - return Response( - {'error': 'Setting not found'}, - status=status.HTTP_404_NOT_FOUND + return error_response( + error='Setting not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request ) serializer = self.get_serializer(setting) - return Response(serializer.data) + return success_response(data=serializer.data, request=request) def perform_create(self, serializer): """Set user and account automatically""" @@ -155,11 +174,15 @@ class UserSettingsViewSet(viewsets.ModelViewSet): class ModuleSettingsViewSet(AccountModelViewSet): """ ViewSet for managing module-specific settings + Unified API Standard v1.0 compliant """ queryset = ModuleSettings.objects.all() serializer_class = ModuleSettingsSerializer permission_classes = [permissions.IsAuthenticated] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] + pagination_class = CustomPageNumberPagination + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Get settings for current account, optionally filtered by module""" @@ -174,7 +197,7 @@ class ModuleSettingsViewSet(AccountModelViewSet): """Get all settings for a specific module""" queryset = self.get_queryset().filter(module_name=module_name) serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) + return success_response(data=serializer.data, request=request) def retrieve(self, request, pk=None): """Get setting by key (pk can be key string)""" @@ -189,18 +212,20 @@ class ModuleSettingsViewSet(AccountModelViewSet): try: setting = queryset.get(module_name=module_name, key=pk) except ModuleSettings.DoesNotExist: - return Response( - {'error': 'Setting not found'}, - status=status.HTTP_404_NOT_FOUND + return error_response( + error='Setting not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request ) else: - return Response( - {'error': 'Setting not found'}, - status=status.HTTP_404_NOT_FOUND + return error_response( + error='Setting not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request ) serializer = self.get_serializer(setting) - return Response(serializer.data) + return success_response(data=serializer.data, request=request) def perform_create(self, serializer): """Set account automatically""" @@ -220,11 +245,15 @@ class ModuleSettingsViewSet(AccountModelViewSet): class AISettingsViewSet(AccountModelViewSet): """ ViewSet for managing AI-specific settings + Unified API Standard v1.0 compliant """ queryset = AISettings.objects.all() serializer_class = AISettingsSerializer permission_classes = [permissions.IsAuthenticated] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] + pagination_class = CustomPageNumberPagination + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Get AI settings for current account""" @@ -241,13 +270,14 @@ class AISettingsViewSet(AccountModelViewSet): try: setting = queryset.get(integration_type=pk) except AISettings.DoesNotExist: - return Response( - {'error': 'AI Setting not found'}, - status=status.HTTP_404_NOT_FOUND + return error_response( + error='AI Setting not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request ) serializer = self.get_serializer(setting) - return Response(serializer.data) + return success_response(data=serializer.data, request=request) def perform_create(self, serializer): """Set account automatically""" diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 5bd3d263..10d0de71 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -375,9 +375,13 @@ class TasksViewSet(SiteSectorModelViewSet): class ImagesViewSet(SiteSectorModelViewSet): """ ViewSet for managing content images + Unified API Standard v1.0 compliant """ queryset = Images.objects.all() serializer_class = ImagesSerializer + pagination_class = CustomPageNumberPagination + throttle_scope = 'writer' + throttle_classes = [DebugScopedRateThrottle] filter_backends = [DjangoFilterBackend, filters.OrderingFilter] ordering_fields = ['created_at', 'position', 'id'] @@ -385,12 +389,37 @@ class ImagesViewSet(SiteSectorModelViewSet): filterset_fields = ['task_id', 'content_id', 'image_type', 'status'] def perform_create(self, serializer): - """Override to automatically set account""" - account = getattr(self.request, 'account', None) - if account: - serializer.save(account=account) - else: - serializer.save() + """Override to automatically set account, site, and sector""" + from rest_framework.exceptions import ValidationError + + # Get site and sector from request (set by middleware) or user's active context + site = getattr(self.request, 'site', None) + sector = getattr(self.request, 'sector', None) + + if not site: + # Fallback to user's active site if not set by middleware + user = getattr(self.request, 'user', None) + if user and user.is_authenticated and hasattr(user, 'active_site'): + site = user.active_site + + if not sector and site: + # Fallback to default sector for the site if not set by middleware + from igny8_core.auth.models import Sector + sector = site.sectors.filter(is_default=True).first() + + # Site and sector are required - raise ValidationError if not available + # Use dict format for ValidationError to ensure proper error structure + if not site: + raise ValidationError({"site": ["Site is required for image creation. Please select a site."]}) + if not sector: + raise ValidationError({"sector": ["Sector is required for image creation. Please select a sector."]}) + + # Add site and sector to validated_data so base class can validate access + serializer.validated_data['site'] = site + serializer.validated_data['sector'] = sector + + # Call parent to set account and validate access + super().perform_create(serializer) @action(detail=True, methods=['get'], url_path='file', url_name='image_file') def serve_image_file(self, request, pk=None): diff --git a/frontend/src/components/common/ComponentCard.tsx b/frontend/src/components/common/ComponentCard.tsx index 16831c07..2bf76411 100644 --- a/frontend/src/components/common/ComponentCard.tsx +++ b/frontend/src/components/common/ComponentCard.tsx @@ -1,8 +1,8 @@ interface ComponentCardProps { - title: string; + title: string | React.ReactNode; children: React.ReactNode; className?: string; // Additional custom classes for styling - desc?: string; // Description text + desc?: string | React.ReactNode; // Description text } const ComponentCard: React.FC = ({ diff --git a/frontend/src/components/sidebar/ApiStatusIndicator.tsx b/frontend/src/components/sidebar/ApiStatusIndicator.tsx index 74d41b60..5bd99e76 100644 --- a/frontend/src/components/sidebar/ApiStatusIndicator.tsx +++ b/frontend/src/components/sidebar/ApiStatusIndicator.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { useLocation } from "react-router"; import { API_BASE_URL } from "../../services/api"; import { useAuthStore } from "../../store/authStore"; @@ -37,7 +38,7 @@ const endpointGroups = [ }, { name: "Planner Module", - abbreviation: "PL", + abbreviation: "PM", endpoints: [ { path: "/v1/planner/keywords/", method: "GET" }, { path: "/v1/planner/keywords/auto_cluster/", method: "POST" }, @@ -49,7 +50,7 @@ const endpointGroups = [ }, { name: "Writer Module", - abbreviation: "WR", + abbreviation: "WM", endpoints: [ { path: "/v1/writer/tasks/", method: "GET" }, { path: "/v1/writer/tasks/auto_generate_content/", method: "POST" }, @@ -60,6 +61,48 @@ const endpointGroups = [ { path: "/v1/writer/images/generate_images/", method: "POST" }, ], }, + { + name: "CRUD Operations - Planner", + abbreviation: "PC", + endpoints: [ + { path: "/v1/planner/keywords/", method: "GET" }, + { path: "/v1/planner/keywords/", method: "POST" }, + { path: "/v1/planner/keywords/1/", method: "GET" }, + { path: "/v1/planner/keywords/1/", method: "PUT" }, + { path: "/v1/planner/keywords/1/", method: "DELETE" }, + { path: "/v1/planner/clusters/", method: "GET" }, + { path: "/v1/planner/clusters/", method: "POST" }, + { path: "/v1/planner/clusters/1/", method: "GET" }, + { path: "/v1/planner/clusters/1/", method: "PUT" }, + { path: "/v1/planner/clusters/1/", method: "DELETE" }, + { path: "/v1/planner/ideas/", method: "GET" }, + { path: "/v1/planner/ideas/", method: "POST" }, + { path: "/v1/planner/ideas/1/", method: "GET" }, + { path: "/v1/planner/ideas/1/", method: "PUT" }, + { path: "/v1/planner/ideas/1/", method: "DELETE" }, + ], + }, + { + name: "CRUD Operations - Writer", + abbreviation: "WC", + endpoints: [ + { path: "/v1/writer/tasks/", method: "GET" }, + { path: "/v1/writer/tasks/", method: "POST" }, + { path: "/v1/writer/tasks/1/", method: "GET" }, + { path: "/v1/writer/tasks/1/", method: "PUT" }, + { path: "/v1/writer/tasks/1/", method: "DELETE" }, + { path: "/v1/writer/content/", method: "GET" }, + { path: "/v1/writer/content/", method: "POST" }, + { path: "/v1/writer/content/1/", method: "GET" }, + { path: "/v1/writer/content/1/", method: "PUT" }, + { path: "/v1/writer/content/1/", method: "DELETE" }, + { path: "/v1/writer/images/", method: "GET" }, + { path: "/v1/writer/images/", method: "POST" }, + { path: "/v1/writer/images/1/", method: "GET" }, + { path: "/v1/writer/images/1/", method: "PUT" }, + { path: "/v1/writer/images/1/", method: "DELETE" }, + ], + }, { name: "System & Billing", abbreviation: "SY", @@ -67,7 +110,7 @@ const endpointGroups = [ { path: "/v1/system/prompts/", method: "GET" }, { path: "/v1/system/author-profiles/", method: "GET" }, { path: "/v1/system/strategies/", method: "GET" }, - { path: "/v1/system/settings/integrations/1/test/", method: "POST" }, + { path: "/v1/system/settings/integrations/openai/test/", method: "POST" }, { path: "/v1/system/settings/account/", method: "GET" }, { path: "/v1/billing/credits/balance/balance/", method: "GET" }, { path: "/v1/billing/credits/usage/", method: "GET" }, @@ -79,6 +122,7 @@ const endpointGroups = [ export default function ApiStatusIndicator() { const { user } = useAuthStore(); + const location = useLocation(); const [groupStatuses, setGroupStatuses] = useState([]); const [isChecking, setIsChecking] = useState(false); const intervalRef = useRef | null>(null); @@ -86,8 +130,11 @@ export default function ApiStatusIndicator() { // Only show and run for aws-admin accounts const isAwsAdmin = user?.account?.slug === 'aws-admin'; - // Return null if not aws-admin account - if (!isAwsAdmin) { + // Only run API checks on API monitor page to avoid console errors on other pages + const isApiMonitorPage = location.pathname === '/settings/api-monitor'; + + // Return null if not aws-admin account or not on API monitor page + if (!isAwsAdmin || !isApiMonitorPage) { return null; } @@ -141,8 +188,17 @@ export default function ApiStatusIndicator() { body = { username: 'test', password: 'test' }; } else if (path.includes('/register/')) { body = { username: 'test', email: 'test@test.com', password: 'test' }; + } else if (path.includes('/bulk_delete/')) { + body = { ids: [] }; // Empty array to trigger validation error + } else if (path.includes('/bulk_update/')) { + body = { ids: [] }; // Empty array to trigger validation error } fetchOptions.body = JSON.stringify(body); + } else if (method === 'PUT' || method === 'DELETE') { + // For PUT/DELETE, we need to send a body for PUT or handle DELETE + if (method === 'PUT') { + fetchOptions.body = JSON.stringify({}); // Empty object to trigger validation + } } // Suppress console errors for expected 400 responses (validation errors from test data) @@ -154,11 +210,13 @@ export default function ApiStatusIndicator() { path.includes('/test/') ); - const response = await fetch(`${API_BASE_URL}${path}`, fetchOptions); - - // Suppress console errors for expected 400 responses - if (!isExpected400 || response.status !== 400) { - // Only log if it's not an expected 400 + // Use a silent fetch that won't log to console for expected errors + let response: Response; + try { + response = await fetch(`${API_BASE_URL}${path}`, fetchOptions); + } catch (fetchError) { + // Network errors are real errors + return 'error'; } if (actualMethod === 'OPTIONS') { @@ -176,24 +234,31 @@ export default function ApiStatusIndicator() { } else if (response.status === 401 || response.status === 403) { return 'warning'; } else if (response.status === 404) { - return 'error'; + // For GET requests to specific resource IDs (e.g., /v1/planner/keywords/1/), + // 404 is expected and healthy (resource doesn't exist, but endpoint works correctly) + // For other GET requests (like list endpoints), 404 means endpoint doesn't exist + const isResourceByIdRequest = /\/\d+\/?$/.test(path); // Path ends with /number/ or /number + if (isResourceByIdRequest) { + return 'healthy'; // GET to specific ID returning 404 is healthy (endpoint exists, resource doesn't) + } + return 'error'; // Endpoint doesn't exist } else if (response.status >= 500) { return 'error'; } return 'warning'; } else if (method === 'POST') { // Suppress console errors for expected 400 responses (validation errors from test data) + // CRUD POST endpoints (like /v1/planner/keywords/, /v1/writer/tasks/) return 400 for empty/invalid test data const isExpected400 = path.includes('/login/') || path.includes('/register/') || path.includes('/bulk_') || - path.includes('/test/'); + path.includes('/test/') || + // CRUD CREATE endpoints - POST to list endpoints (no ID in path, ends with / or exact match) + /\/v1\/(planner|writer)\/(keywords|clusters|ideas|tasks|content|images)\/?$/.test(path); if (response.status === 400) { // 400 is expected for test requests - endpoint is working - if (!isExpected400) { - // Only log if it's unexpected - console.warn(`[ApiStatusIndicator] ${method} ${path}: 400 (unexpected)`); - } + // Don't log warnings for expected 400s - they're normal validation errors return 'healthy'; } else if (response.status >= 200 && response.status < 300) { return 'healthy'; diff --git a/frontend/src/config/pages/keywords.config.tsx b/frontend/src/config/pages/keywords.config.tsx index 97240e57..656a505d 100644 --- a/frontend/src/config/pages/keywords.config.tsx +++ b/frontend/src/config/pages/keywords.config.tsx @@ -515,7 +515,7 @@ export const createKeywordsPageConfig = ( label: 'Seed Keyword', type: 'select', placeholder: 'Select a seed keyword', - value: handlers.formData.seed_keyword_id?.toString() || '', + value: (handlers.formData.seed_keyword_id && handlers.formData.seed_keyword_id > 0) ? handlers.formData.seed_keyword_id.toString() : '', onChange: (value: any) => handlers.setFormData({ ...handlers.formData, seed_keyword_id: value ? parseInt(value) : 0 }), required: true, diff --git a/frontend/src/pages/Planner/Keywords.tsx b/frontend/src/pages/Planner/Keywords.tsx index 51900a18..99e081e5 100644 --- a/frontend/src/pages/Planner/Keywords.tsx +++ b/frontend/src/pages/Planner/Keywords.tsx @@ -874,6 +874,7 @@ export default function Keywords() { {/* Create/Edit Modal */} { setIsModalOpen(false); diff --git a/frontend/src/pages/Settings/ApiMonitor.tsx b/frontend/src/pages/Settings/ApiMonitor.tsx index e4368cc1..1aa4e56a 100644 --- a/frontend/src/pages/Settings/ApiMonitor.tsx +++ b/frontend/src/pages/Settings/ApiMonitor.tsx @@ -299,7 +299,27 @@ export default function ApiMonitor() { fetchOptions.body = JSON.stringify({}); } - const response = await fetch(`${API_BASE_URL}${path}`, fetchOptions); + let response: Response; + try { + response = await fetch(`${API_BASE_URL}${path}`, fetchOptions); + } catch (error: any) { + // Network error or fetch failed + const responseTime = Date.now() - startTime; + setEndpointStatuses(prev => ({ + ...prev, + [key]: { + endpoint: path, + method, + status: 'error', + responseTime, + error: error.message || 'Network error', + apiStatus: 'error', + dataStatus: 'error', + }, + })); + return; + } + const responseTime = Date.now() - startTime; // Determine status based on response @@ -349,38 +369,55 @@ export default function ApiMonitor() { if (responseData.success === false) { status = 'error'; // API returned an error in unified format } else if (responseData.success === true) { - // Check if data is empty for endpoints that should return data - // These endpoints should have data: {count: X, results: [...]} or data: {...} - const shouldHaveData = - path.includes('/content_images/') || - path.includes('/prompts/by_type/') || - path.includes('/usage/limits/') || - path.includes('/prompts/') && !path.includes('/save/'); + // Check for paginated response format (success: true, count: X, results: [...]) + // or single object response format (success: true, data: {...}) + const isPaginatedResponse = 'results' in responseData && 'count' in responseData; + const isSingleObjectResponse = 'data' in responseData; - if (shouldHaveData) { - // Check if data field exists and has content - if (responseData.data === null || responseData.data === undefined) { - status = 'warning'; // Missing data field - } else if (Array.isArray(responseData.data) && responseData.data.length === 0) { - // Empty array might be OK for some endpoints, but check if results should exist + if (isPaginatedResponse) { + // Paginated response - check results at top level + if (!Array.isArray(responseData.results)) { + status = 'warning'; // Missing or invalid results array + } else if (responseData.results.length === 0 && responseData.count === 0) { + // Empty results with count 0 is OK for list endpoints + // Only warn for critical endpoints that should have data if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) { - // These endpoints should return data, empty might indicate a problem - status = 'warning'; // Empty data - might indicate configuration issue + status = 'warning'; // No data available - might indicate configuration issue } - } else if (typeof responseData.data === 'object' && responseData.data !== null) { - // Check if it's a paginated response with empty results - if (responseData.data.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) { - // Empty results might be OK, but for critical endpoints it's a warning + } + } else if (isSingleObjectResponse) { + // Single object response - check data field + const shouldHaveData = + path.includes('/content_images/') || + path.includes('/prompts/by_type/') || + path.includes('/usage/limits/'); + + if (shouldHaveData) { + if (responseData.data === null || responseData.data === undefined) { + status = 'warning'; // Missing data field + } else if (Array.isArray(responseData.data) && responseData.data.length === 0) { if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) { - status = 'warning'; // Empty results - might indicate data issue + status = 'warning'; // Empty data - might indicate configuration issue } - } else if (responseData.data.count !== undefined && responseData.data.count === 0) { - // Paginated response with count: 0 - if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) { - status = 'warning'; // No data available - might indicate configuration issue + } else if (typeof responseData.data === 'object' && responseData.data !== null) { + // Check if it's a nested paginated response + if (responseData.data.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) { + if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) { + status = 'warning'; // Empty results - might indicate data issue + } + } else if (responseData.data.count !== undefined && responseData.data.count === 0) { + if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) { + status = 'warning'; // No data available - might indicate configuration issue + } } } } + } else if (!isPaginatedResponse && !isSingleObjectResponse) { + // Response has success: true but no data or results + // For paginated list endpoints, this is a problem + if (path.includes('/prompts/') && !path.includes('/save/') && !path.includes('/by_type/')) { + status = 'warning'; // Paginated endpoint missing results field + } } } } @@ -394,7 +431,15 @@ export default function ApiMonitor() { } else if (response.status === 401 || response.status === 403) { status = 'warning'; // Endpoint exists, needs authentication } else if (response.status === 404) { - status = 'error'; // Endpoint doesn't exist + // For GET requests to specific resource IDs (e.g., /v1/planner/keywords/1/), + // 404 is expected and healthy (resource doesn't exist, but endpoint works correctly) + // For other GET requests (like list endpoints), 404 means endpoint doesn't exist + const isResourceByIdRequest = /\/\d+\/?$/.test(path); // Path ends with /number/ or /number + if (method === 'GET' && isResourceByIdRequest) { + status = 'healthy'; // GET to specific ID returning 404 is healthy (endpoint exists, resource doesn't) + } else { + status = 'error'; // Endpoint doesn't exist + } } else if (response.status >= 500) { status = 'error'; } else { @@ -510,18 +555,42 @@ export default function ApiMonitor() { } // Log warnings/errors for issues detected in response content - if (status === 'warning' || status === 'error') { + // Skip logging for expected 400 responses on POST (validation errors are expected) + const isExpected400Post = method === 'POST' && response.status === 400; + if ((status === 'warning' || status === 'error') && !isExpected400Post) { if (responseData) { if (responseData.success === false) { console.warn(`[API Monitor] ${method} ${path}: Unified format error - ${responseData.error || 'Unknown error'}`); - } else if (responseData.data === null || responseData.data === undefined) { - console.warn(`[API Monitor] ${method} ${path}: Missing data field in response`); - } else if (Array.isArray(responseData.data) && responseData.data.length === 0) { - console.warn(`[API Monitor] ${method} ${path}: Empty data array returned`); - } else if (responseData.data?.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) { - console.warn(`[API Monitor] ${method} ${path}: Empty results array returned`); - } else if (responseData.data?.count === 0) { - console.warn(`[API Monitor] ${method} ${path}: No data available (count: 0)`); + } else { + // Check for paginated response format + const isPaginated = 'results' in responseData && 'count' in responseData; + const isSingleObject = 'data' in responseData; + + if (isPaginated) { + // Paginated response - check results at top level + if (!Array.isArray(responseData.results)) { + console.warn(`[API Monitor] ${method} ${path}: Missing or invalid results array in paginated response`); + } else if (responseData.results.length === 0 && responseData.count === 0 && + (path.includes('/prompts/') && !path.includes('/save/') && !path.includes('/by_type/'))) { + console.warn(`[API Monitor] ${method} ${path}: Empty paginated response (count: 0, results: [])`); + } + } else if (isSingleObject) { + // Single object response - check data field + if (responseData.data === null || responseData.data === undefined) { + console.warn(`[API Monitor] ${method} ${path}: Missing data field in response`); + } else if (Array.isArray(responseData.data) && responseData.data.length === 0) { + console.warn(`[API Monitor] ${method} ${path}: Empty data array returned`); + } else if (responseData.data?.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) { + console.warn(`[API Monitor] ${method} ${path}: Empty results array returned`); + } else if (responseData.data?.count === 0) { + console.warn(`[API Monitor] ${method} ${path}: No data available (count: 0)`); + } + } else if (responseData.success === true && !isPaginated && !isSingleObject) { + // Response has success: true but no data or results + if (path.includes('/prompts/') && !path.includes('/save/') && !path.includes('/by_type/')) { + console.warn(`[API Monitor] ${method} ${path}: Paginated endpoint missing results field`); + } + } } } } @@ -529,13 +598,16 @@ export default function ApiMonitor() { // Suppress console errors for expected monitoring responses // Only log real errors (5xx, network errors, or unexpected 4xx for GET endpoints) // Don't log expected 400s for POST endpoints (they indicate validation is working) + // Don't log expected 404s for GET requests to specific resource IDs (they indicate endpoint works correctly) + const isResourceByIdRequest = /\/\d+\/?$/.test(path); // Path ends with /number/ or /number const isExpectedResponse = (method === 'POST' && response.status === 400) || // Expected validation error (actualMethod === 'OPTIONS' && response.status === 200) || // Expected OPTIONS success - (method === 'GET' && response.status >= 200 && response.status < 300 && status === 'healthy'); // Expected GET success with valid data + (method === 'GET' && response.status >= 200 && response.status < 300 && status === 'healthy') || // Expected GET success with valid data + (method === 'GET' && response.status === 404 && isResourceByIdRequest); // Expected 404 for GET to non-existent resource ID if (!isExpectedResponse && (response.status >= 500 || - (method === 'GET' && response.status === 404) || + (method === 'GET' && response.status === 404 && !isResourceByIdRequest) || (actualMethod === 'OPTIONS' && response.status !== 200))) { // These are real errors worth logging console.warn(`[API Monitor] ${method} ${path}: ${response.status}`, responseText.substring(0, 100)); @@ -622,10 +694,43 @@ export default function ApiMonitor() { const getGroupHealth = (group: EndpointGroup) => { const statuses = group.endpoints.map(ep => getEndpointStatus(ep.path, ep.method).status); const healthy = statuses.filter(s => s === 'healthy').length; + const warning = statuses.filter(s => s === 'warning').length; + const error = statuses.filter(s => s === 'error').length; const total = statuses.length; - return { healthy, total }; + return { healthy, warning, error, total }; }; + const getGroupStatus = (group: EndpointGroup): 'error' | 'warning' | 'healthy' => { + const health = getGroupHealth(group); + if (health.error > 0) return 'error'; + if (health.warning > 0) return 'warning'; + return 'healthy'; + }; + + const getStatusPriority = (status: 'error' | 'warning' | 'healthy'): number => { + switch (status) { + case 'error': return 0; + case 'warning': return 1; + case 'healthy': return 2; + default: return 3; + } + }; + + // Sort endpoint groups by status (error > warning > healthy) + const sortedEndpointGroups = [...endpointGroups].sort((a, b) => { + const statusA = getGroupStatus(a); + const statusB = getGroupStatus(b); + const priorityA = getStatusPriority(statusA); + const priorityB = getStatusPriority(statusB); + + // If same priority, sort by name + if (priorityA === priorityB) { + return a.name.localeCompare(b.name); + } + + return priorityA - priorityB; + }); + return ( <> @@ -682,13 +787,27 @@ export default function ApiMonitor() { {/* Monitoring Tables - 3 per row */}
- {endpointGroups.map((group, groupIndex) => { + {sortedEndpointGroups.map((group, groupIndex) => { const groupHealth = getGroupHealth(group); + const groupStatus = getGroupStatus(group); return ( + {group.name} + + {getStatusIcon(groupStatus)} + +
+ } + desc={ + groupStatus === 'error' + ? `${groupHealth.error} error${groupHealth.error !== 1 ? 's' : ''}, ${groupHealth.warning} warning${groupHealth.warning !== 1 ? 's' : ''}, ${groupHealth.healthy} healthy` + : groupStatus === 'warning' + ? `${groupHealth.warning} warning${groupHealth.warning !== 1 ? 's' : ''}, ${groupHealth.healthy} healthy` + : `${groupHealth.healthy}/${groupHealth.total} healthy` + } >
@@ -706,8 +825,24 @@ export default function ApiMonitor() { - {group.endpoints.map((endpoint, epIndex) => { - const status = getEndpointStatus(endpoint.path, endpoint.method); + {group.endpoints + .map((endpoint, epIndex) => ({ + endpoint, + epIndex, + status: getEndpointStatus(endpoint.path, endpoint.method), + })) + .sort((a, b) => { + const priorityA = getStatusPriority(a.status.status); + const priorityB = getStatusPriority(b.status.status); + // If same priority, sort by method then path + if (priorityA === priorityB) { + const methodCompare = a.endpoint.method.localeCompare(b.endpoint.method); + if (methodCompare !== 0) return methodCompare; + return a.endpoint.path.localeCompare(b.endpoint.path); + } + return priorityA - priorityB; + }) + .map(({ endpoint, epIndex, status }) => { return (
@@ -767,22 +902,26 @@ export default function ApiMonitor() { {/* Summary Stats */}
- {endpointGroups.map((group, index) => { + {sortedEndpointGroups.map((group, index) => { const groupHealth = getGroupHealth(group); + const groupStatus = getGroupStatus(group); const percentage = groupHealth.total > 0 ? Math.round((groupHealth.healthy / groupHealth.total) * 100) : 0; return (
-
+
{percentage}%
-
- {group.name} +
+ {group.name} + {getStatusIcon(groupStatus)}
{groupHealth.healthy}/{groupHealth.total} healthy + {groupHealth.error > 0 && ` • ${groupHealth.error} error${groupHealth.error !== 1 ? 's' : ''}`} + {groupHealth.warning > 0 && ` • ${groupHealth.warning} warning${groupHealth.warning !== 1 ? 's' : ''}`}
); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7ec3061e..31ef6711 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -640,18 +640,33 @@ export async function autoClusterKeywords(keywordIds: number[], sectorId?: numbe const requestBody = { ids: keywordIds, sector_id: sectorId }; try { + // fetchAPI will automatically extract data from unified format + // For action endpoints, response is {success: true, data: {...}} + // fetchAPI extracts and returns the data field, so response should already be the data object const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); - // Check if response indicates an error (success: false) - if (response && response.success === false) { - // Return error response as-is so caller can check result.success - return response; + // After fetchAPI processing, response should be the data object (not wrapped in success/data) + // But check if it's still wrapped (shouldn't happen, but for safety) + if (response && typeof response === 'object') { + if ('success' in response && response.success === false) { + // Error response - return as-is + return response as any; + } + // If response has data field, extract it + if ('data' in response && response.data) { + return { success: true, ...response.data } as any; + } + // Response is already the data object (after fetchAPI extraction) + // Ensure it has success: true + if (!('success' in response)) { + return { success: true, ...response } as any; + } } - return response; + return response as any; } catch (error: any) { throw error; }