From 5b11c4001e661688a39cc54eb6cf0748ba490682 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:37:41 +0000 Subject: [PATCH 01/35] Phase 0: Update credit costs and CreditService, add credit checks to AI Engine - Updated CREDIT_COSTS to match Phase 0 spec (flat structure) - Added get_credit_cost() method to CreditService - Updated check_credits() to accept operation_type and amount - Added deduct_credits_for_operation() convenience method - Updated AI Engine to check credits BEFORE AI call - Updated AI Engine to deduct credits AFTER successful execution - Added helper methods for operation type mapping and amount calculation --- backend/igny8_core/ai/engine.py | 147 ++++++++++++---- .../igny8_core/modules/billing/constants.py | 31 ++-- .../igny8_core/modules/billing/services.py | 159 +++++++++++++++--- 3 files changed, 264 insertions(+), 73 deletions(-) diff --git a/backend/igny8_core/ai/engine.py b/backend/igny8_core/ai/engine.py index 4161df28..39542098 100644 --- a/backend/igny8_core/ai/engine.py +++ b/backend/igny8_core/ai/engine.py @@ -192,6 +192,31 @@ class AIEngine: self.step_tracker.add_request_step("PREP", "success", prep_message) self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta()) + # Phase 2.5: CREDIT CHECK - Check credits before AI call (25%) + if self.account: + try: + from igny8_core.modules.billing.services import CreditService + from igny8_core.modules.billing.exceptions import InsufficientCreditsError + + # Map function name to operation type + operation_type = self._get_operation_type(function_name) + + # Calculate estimated cost + estimated_amount = self._get_estimated_amount(function_name, data, payload) + + # Check credits BEFORE AI call + CreditService.check_credits(self.account, operation_type, estimated_amount) + + logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}") + except InsufficientCreditsError as e: + error_msg = str(e) + error_type = 'InsufficientCreditsError' + logger.error(f"[AIEngine] {error_msg}") + return self._handle_error(error_msg, fn, error_type=error_type) + except Exception as e: + logger.warning(f"[AIEngine] Failed to check credits: {e}", exc_info=True) + # Don't fail the operation if credit check fails (for backward compatibility) + # Phase 3: AI_CALL - Provider API Call (25-70%) # Validate account exists before proceeding if not self.account: @@ -325,37 +350,45 @@ class AIEngine: # Store save_msg for use in DONE phase final_save_msg = save_msg - # Track credit usage after successful save + # Phase 5.5: DEDUCT CREDITS - Deduct credits after successful save if self.account and raw_response: try: from igny8_core.modules.billing.services import CreditService - from igny8_core.modules.billing.models import CreditUsageLog + from igny8_core.modules.billing.exceptions import InsufficientCreditsError - # Calculate credits used (based on tokens or fixed cost) - credits_used = self._calculate_credits_for_clustering( - keyword_count=len(data.get('keywords', [])) if isinstance(data, dict) else len(data) if isinstance(data, list) else 1, - tokens=raw_response.get('total_tokens', 0), - cost=raw_response.get('cost', 0) - ) + # Map function name to operation type + operation_type = self._get_operation_type(function_name) - # Log credit usage (don't deduct from account.credits, just log) - CreditUsageLog.objects.create( + # Calculate actual amount based on results + actual_amount = self._get_actual_amount(function_name, save_result, parsed, data) + + # Deduct credits using the new convenience method + CreditService.deduct_credits_for_operation( account=self.account, - operation_type='clustering', - credits_used=credits_used, + operation_type=operation_type, + amount=actual_amount, cost_usd=raw_response.get('cost'), model_used=raw_response.get('model', ''), tokens_input=raw_response.get('tokens_input', 0), tokens_output=raw_response.get('tokens_output', 0), - related_object_type='cluster', + related_object_type=self._get_related_object_type(function_name), + related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'), metadata={ + 'function_name': function_name, 'clusters_created': clusters_created, 'keywords_updated': keywords_updated, - 'function_name': function_name + 'count': count, + **save_result } ) + + logger.info(f"[AIEngine] Credits deducted: {operation_type}, amount: {actual_amount}") + except InsufficientCreditsError as e: + # This shouldn't happen since we checked before, but log it + logger.error(f"[AIEngine] Insufficient credits during deduction: {e}") except Exception as e: - logger.warning(f"Failed to log credit usage: {e}", exc_info=True) + logger.warning(f"[AIEngine] Failed to deduct credits: {e}", exc_info=True) + # Don't fail the operation if credit deduction fails (for backward compatibility) # Phase 6: DONE - Finalization (98-100%) success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully" @@ -453,18 +486,74 @@ class AIEngine: # Don't fail the task if logging fails logger.warning(f"Failed to log to database: {e}") - def _calculate_credits_for_clustering(self, keyword_count, tokens, cost): - """Calculate credits used for clustering operation""" - # Use plan's cost per request if available, otherwise calculate from tokens - if self.account and hasattr(self.account, 'plan') and self.account.plan: - plan = self.account.plan - # Check if plan has ai_cost_per_request config - if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request: - cluster_cost = plan.ai_cost_per_request.get('cluster', 0) - if cluster_cost: - return int(cluster_cost) - - # Fallback: 1 credit per 30 keywords (minimum 1) - credits = max(1, int(keyword_count / 30)) - return credits + def _get_operation_type(self, function_name): + """Map function name to operation type for credit system""" + mapping = { + 'auto_cluster': 'clustering', + 'generate_ideas': 'idea_generation', + 'generate_content': 'content_generation', + 'generate_image_prompts': 'image_prompt_extraction', + 'generate_images': 'image_generation', + } + return mapping.get(function_name, function_name) + + def _get_estimated_amount(self, function_name, data, payload): + """Get estimated amount for credit calculation (before operation)""" + if function_name == 'generate_content': + # Estimate word count from task or default + if isinstance(data, dict): + return data.get('estimated_word_count', 1000) + return 1000 # Default estimate + elif function_name == 'generate_images': + # Count images to generate + if isinstance(payload, dict): + image_ids = payload.get('image_ids', []) + return len(image_ids) if image_ids else 1 + return 1 + elif function_name == 'generate_ideas': + # Count clusters + if isinstance(data, dict) and 'cluster_data' in data: + return len(data['cluster_data']) + return 1 + # For fixed cost operations (clustering, image_prompt_extraction), return None + return None + + def _get_actual_amount(self, function_name, save_result, parsed, data): + """Get actual amount for credit calculation (after operation)""" + if function_name == 'generate_content': + # Get actual word count from saved content + if isinstance(save_result, dict): + word_count = save_result.get('word_count') + if word_count: + return word_count + # Fallback: estimate from parsed content + if isinstance(parsed, dict) and 'content' in parsed: + content = parsed['content'] + return len(content.split()) if isinstance(content, str) else 1000 + return 1000 + elif function_name == 'generate_images': + # Count successfully generated images + count = save_result.get('count', 0) + if count > 0: + return count + return 1 + elif function_name == 'generate_ideas': + # Count ideas generated + count = save_result.get('count', 0) + if count > 0: + return count + return 1 + # For fixed cost operations, return None + return None + + def _get_related_object_type(self, function_name): + """Get related object type for credit logging""" + mapping = { + 'auto_cluster': 'cluster', + 'generate_ideas': 'content_idea', + 'generate_content': 'content', + 'generate_image_prompts': 'image', + 'generate_images': 'image', + } + return mapping.get(function_name, 'unknown') diff --git a/backend/igny8_core/modules/billing/constants.py b/backend/igny8_core/modules/billing/constants.py index 13f60131..ff669180 100644 --- a/backend/igny8_core/modules/billing/constants.py +++ b/backend/igny8_core/modules/billing/constants.py @@ -1,22 +1,21 @@ """ Credit Cost Constants +Phase 0: Credit-only system costs per operation """ CREDIT_COSTS = { - 'clustering': { - 'base': 1, # 1 credit per 30 keywords - 'per_keyword': 1 / 30, - }, - 'ideas': { - 'base': 1, # 1 credit per idea - }, - 'content': { - 'base': 3, # 3 credits per full blog post - }, - 'images': { - 'base': 1, # 1 credit per image - }, - 'reparse': { - 'base': 1, # 1 credit per reparse - }, + 'clustering': 10, # Per clustering request + 'idea_generation': 15, # Per cluster → ideas request + 'content_generation': 1, # Per 100 words + 'image_prompt_extraction': 2, # Per content piece + 'image_generation': 5, # Per image + 'linking': 8, # Per content piece (NEW) + 'optimization': 1, # Per 200 words (NEW) + 'site_structure_generation': 50, # Per site blueprint (NEW) + 'site_page_generation': 20, # Per page (NEW) + # Legacy operation types (for backward compatibility) + 'ideas': 15, # Alias for idea_generation + 'content': 3, # Legacy: 3 credits per content piece + 'images': 5, # Alias for image_generation + 'reparse': 1, # Per reparse } diff --git a/backend/igny8_core/modules/billing/services.py b/backend/igny8_core/modules/billing/services.py index 79a5651b..64a8024e 100644 --- a/backend/igny8_core/modules/billing/services.py +++ b/backend/igny8_core/modules/billing/services.py @@ -13,9 +13,65 @@ class CreditService: """Service for managing credits""" @staticmethod - def check_credits(account, required_credits): + def get_credit_cost(operation_type, amount=None): """ - Check if account has enough credits. + Get credit cost for operation. + + Args: + operation_type: Type of operation (from CREDIT_COSTS) + amount: Optional amount (word count, image count, etc.) + + Returns: + int: Number of credits required + + Raises: + CreditCalculationError: If operation type is unknown + """ + base_cost = CREDIT_COSTS.get(operation_type, 0) + if base_cost == 0: + raise CreditCalculationError(f"Unknown operation type: {operation_type}") + + # Variable cost operations + if operation_type == 'content_generation' and amount: + # Per 100 words + return max(1, int(base_cost * (amount / 100))) + elif operation_type == 'optimization' and amount: + # Per 200 words + return max(1, int(base_cost * (amount / 200))) + elif operation_type == 'image_generation' and amount: + # Per image + return base_cost * amount + elif operation_type == 'idea_generation' and amount: + # Per idea + return base_cost * amount + + # Fixed cost operations + return base_cost + + @staticmethod + def check_credits(account, operation_type, amount=None): + """ + Check if account has sufficient credits for an operation. + + Args: + account: Account instance + operation_type: Type of operation + amount: Optional amount (word count, image count, etc.) + + Raises: + InsufficientCreditsError: If account doesn't have enough credits + """ + required = CreditService.get_credit_cost(operation_type, amount) + if account.credits < required: + raise InsufficientCreditsError( + f"Insufficient credits. Required: {required}, Available: {account.credits}" + ) + return True + + @staticmethod + def check_credits_legacy(account, required_credits): + """ + Legacy method: Check if account has enough credits (for backward compatibility). Args: account: Account instance @@ -51,8 +107,8 @@ class CreditService: Returns: int: New credit balance """ - # Check sufficient credits - CreditService.check_credits(account, amount) + # Check sufficient credits (legacy: amount is already calculated) + CreditService.check_credits_legacy(account, amount) # Deduct from account.credits account.credits -= amount @@ -84,6 +140,61 @@ class CreditService: return account.credits + @staticmethod + @transaction.atomic + def deduct_credits_for_operation(account, operation_type, amount=None, description=None, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None): + """ + Deduct credits for an operation (convenience method that calculates cost automatically). + + Args: + account: Account instance + operation_type: Type of operation + amount: Optional amount (word count, image count, etc.) + description: Optional description (auto-generated if not provided) + metadata: Optional metadata dict + cost_usd: Optional cost in USD + model_used: Optional AI model used + tokens_input: Optional input tokens + tokens_output: Optional output tokens + related_object_type: Optional related object type + related_object_id: Optional related object ID + + Returns: + int: New credit balance + """ + # Calculate credit cost + credits_required = CreditService.get_credit_cost(operation_type, amount) + + # Check sufficient credits + CreditService.check_credits(account, operation_type, amount) + + # Auto-generate description if not provided + if not description: + if operation_type == 'clustering': + description = f"Clustering operation" + elif operation_type == 'idea_generation': + description = f"Generated {amount or 1} idea(s)" + elif operation_type == 'content_generation': + description = f"Generated content ({amount or 0} words)" + elif operation_type == 'image_generation': + description = f"Generated {amount or 1} image(s)" + else: + description = f"{operation_type} operation" + + return CreditService.deduct_credits( + account=account, + amount=credits_required, + operation_type=operation_type, + description=description, + metadata=metadata, + cost_usd=cost_usd, + model_used=model_used, + tokens_input=tokens_input, + tokens_output=tokens_output, + related_object_type=related_object_type, + related_object_id=related_object_id + ) + @staticmethod @transaction.atomic def add_credits(account, amount, transaction_type, description, metadata=None): @@ -120,6 +231,7 @@ class CreditService: def calculate_credits_for_operation(operation_type, **kwargs): """ Calculate credits needed for an operation. + Legacy method - use get_credit_cost() instead. Args: operation_type: Type of operation @@ -131,31 +243,22 @@ class CreditService: Raises: CreditCalculationError: If calculation fails """ - if operation_type not in CREDIT_COSTS: - raise CreditCalculationError(f"Unknown operation type: {operation_type}") - - cost_config = CREDIT_COSTS[operation_type] - - if operation_type == 'clustering': - # 1 credit per 30 keywords - keyword_count = kwargs.get('keyword_count', 0) - credits = max(1, int(keyword_count * cost_config['per_keyword'])) - return credits - elif operation_type == 'ideas': - # 1 credit per idea - idea_count = kwargs.get('idea_count', 1) - return cost_config['base'] * idea_count + # Map legacy operation types + if operation_type == 'ideas': + operation_type = 'idea_generation' elif operation_type == 'content': - # 3 credits per content piece - content_count = kwargs.get('content_count', 1) - return cost_config['base'] * content_count + operation_type = 'content_generation' elif operation_type == 'images': - # 1 credit per image - image_count = kwargs.get('image_count', 1) - return cost_config['base'] * image_count - elif operation_type == 'reparse': - # 1 credit per reparse - return cost_config['base'] + operation_type = 'image_generation' - return cost_config['base'] + # Extract amount from kwargs + amount = None + if 'word_count' in kwargs: + amount = kwargs.get('word_count') + elif 'image_count' in kwargs: + amount = kwargs.get('image_count') + elif 'idea_count' in kwargs: + amount = kwargs.get('idea_count') + + return CreditService.get_credit_cost(operation_type, amount) From a73b2ae22be8b302bc55bcd9eff8c4a8900db1bf Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:39:16 +0000 Subject: [PATCH 02/35] Phase 0: Add ModuleEnableSettings model and migration - Created ModuleEnableSettings model with enabled flags for all modules - Added migration for ModuleEnableSettings - Updated imports across system module files --- .../0007_add_module_enable_settings.py | 37 +++++++++++++++++ backend/igny8_core/modules/system/models.py | 2 +- .../modules/system/settings_admin.py | 2 +- .../modules/system/settings_models.py | 40 +++++++++++++++++++ .../modules/system/settings_serializers.py | 2 +- .../modules/system/settings_views.py | 2 +- 6 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 backend/igny8_core/modules/system/migrations/0007_add_module_enable_settings.py diff --git a/backend/igny8_core/modules/system/migrations/0007_add_module_enable_settings.py b/backend/igny8_core/modules/system/migrations/0007_add_module_enable_settings.py new file mode 100644 index 00000000..2a03b28d --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0007_add_module_enable_settings.py @@ -0,0 +1,37 @@ +# Generated manually for Phase 0: Module Enable Settings + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_modules_system', '0006_alter_systemstatus_unique_together_and_more'), + ('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ModuleEnableSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('planner_enabled', models.BooleanField(default=True, help_text='Enable Planner module')), + ('writer_enabled', models.BooleanField(default=True, help_text='Enable Writer module')), + ('thinker_enabled', models.BooleanField(default=True, help_text='Enable Thinker module')), + ('automation_enabled', models.BooleanField(default=True, help_text='Enable Automation module')), + ('site_builder_enabled', models.BooleanField(default=True, help_text='Enable Site Builder module')), + ('linker_enabled', models.BooleanField(default=True, help_text='Enable Linker module')), + ('optimizer_enabled', models.BooleanField(default=True, help_text='Enable Optimizer module')), + ('publisher_enabled', models.BooleanField(default=True, help_text='Enable Publisher module')), + ('account', models.ForeignKey(on_delete=models.CASCADE, to='igny8_core_auth.account', db_column='tenant_id')), + ], + options={ + 'db_table': 'igny8_module_enable_settings', + }, + ), + migrations.AddConstraint( + model_name='moduleenablesettings', + constraint=models.UniqueConstraint(fields=('account',), name='unique_account_module_enable_settings'), + ), + ] + diff --git a/backend/igny8_core/modules/system/models.py b/backend/igny8_core/modules/system/models.py index 219412bc..2d21480d 100644 --- a/backend/igny8_core/modules/system/models.py +++ b/backend/igny8_core/modules/system/models.py @@ -6,7 +6,7 @@ from igny8_core.auth.models import AccountBaseModel # Import settings models from .settings_models import ( - SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings + SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings ) diff --git a/backend/igny8_core/modules/system/settings_admin.py b/backend/igny8_core/modules/system/settings_admin.py index a6a0ded6..bf5ffeea 100644 --- a/backend/igny8_core/modules/system/settings_admin.py +++ b/backend/igny8_core/modules/system/settings_admin.py @@ -3,7 +3,7 @@ Settings Models Admin """ from django.contrib import admin from igny8_core.admin.base import AccountAdminMixin -from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings +from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings @admin.register(SystemSettings) diff --git a/backend/igny8_core/modules/system/settings_models.py b/backend/igny8_core/modules/system/settings_models.py index 075678e7..f2ec58d4 100644 --- a/backend/igny8_core/modules/system/settings_models.py +++ b/backend/igny8_core/modules/system/settings_models.py @@ -92,6 +92,46 @@ class ModuleSettings(BaseSettings): return f"ModuleSetting: {self.module_name} - {self.key}" +class ModuleEnableSettings(AccountBaseModel): + """Module enable/disable settings per account""" + planner_enabled = models.BooleanField(default=True, help_text="Enable Planner module") + writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module") + thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker module") + automation_enabled = models.BooleanField(default=True, help_text="Enable Automation module") + site_builder_enabled = models.BooleanField(default=True, help_text="Enable Site Builder module") + linker_enabled = models.BooleanField(default=True, help_text="Enable Linker module") + optimizer_enabled = models.BooleanField(default=True, help_text="Enable Optimizer module") + publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher module") + + class Meta: + db_table = 'igny8_module_enable_settings' + unique_together = [['account']] # One record per account + + def __str__(self): + account = getattr(self, 'account', None) + return f"ModuleEnableSettings: {account.name if account else 'No Account'}" + + @classmethod + def get_or_create_for_account(cls, account): + """Get or create module enable settings for an account""" + settings, created = cls.objects.get_or_create(account=account) + return settings + + def is_module_enabled(self, module_name): + """Check if a module is enabled""" + mapping = { + 'planner': self.planner_enabled, + 'writer': self.writer_enabled, + 'thinker': self.thinker_enabled, + 'automation': self.automation_enabled, + 'site_builder': self.site_builder_enabled, + 'linker': self.linker_enabled, + 'optimizer': self.optimizer_enabled, + 'publisher': self.publisher_enabled, + } + return mapping.get(module_name, True) # Default to enabled if unknown + + # AISettings extends IntegrationSettings (which already exists) # We'll create it as a separate model that can reference IntegrationSettings class AISettings(AccountBaseModel): diff --git a/backend/igny8_core/modules/system/settings_serializers.py b/backend/igny8_core/modules/system/settings_serializers.py index 7bc10e9a..6233b893 100644 --- a/backend/igny8_core/modules/system/settings_serializers.py +++ b/backend/igny8_core/modules/system/settings_serializers.py @@ -2,7 +2,7 @@ Serializers for Settings Models """ from rest_framework import serializers -from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings +from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings from .validators import validate_settings_schema diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 18da126f..67863ed4 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -13,7 +13,7 @@ from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAu from igny8_core.api.pagination import CustomPageNumberPagination from igny8_core.api.throttles import DebugScopedRateThrottle from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner -from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings +from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings from .settings_serializers import ( SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer, ModuleSettingsSerializer, AISettingsSerializer From 13bd7fa134b0c41fed4960adaae1949eded8fec4 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:40:10 +0000 Subject: [PATCH 03/35] Phase 0: Add ModuleEnableSettings serializer, ViewSet, and URL routing - Created ModuleEnableSettingsSerializer - Created ModuleEnableSettingsViewSet with get_or_create logic - Added URL routing for module enable settings - One record per account, auto-created on first access --- .../modules/system/settings_serializers.py | 11 ++ .../modules/system/settings_views.py | 101 +++++++++++++++++- backend/igny8_core/modules/system/urls.py | 3 +- 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/backend/igny8_core/modules/system/settings_serializers.py b/backend/igny8_core/modules/system/settings_serializers.py index 6233b893..414f9cc7 100644 --- a/backend/igny8_core/modules/system/settings_serializers.py +++ b/backend/igny8_core/modules/system/settings_serializers.py @@ -58,6 +58,17 @@ class ModuleSettingsSerializer(serializers.ModelSerializer): return value +class ModuleEnableSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = ModuleEnableSettings + fields = [ + 'id', 'planner_enabled', 'writer_enabled', 'thinker_enabled', + 'automation_enabled', 'site_builder_enabled', 'linker_enabled', + 'optimizer_enabled', 'publisher_enabled', 'created_at', 'updated_at' + ] + read_only_fields = ['created_at', 'updated_at', 'account'] + + class AISettingsSerializer(serializers.ModelSerializer): class Meta: model = AISettings diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 67863ed4..a423f193 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -16,7 +16,7 @@ from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings from .settings_serializers import ( SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer, - ModuleSettingsSerializer, AISettingsSerializer + ModuleSettingsSerializer, ModuleEnableSettingsSerializer, AISettingsSerializer ) @@ -276,6 +276,105 @@ class ModuleSettingsViewSet(AccountModelViewSet): serializer.save(account=account) +@extend_schema_view( + list=extend_schema(tags=['System']), + retrieve=extend_schema(tags=['System']), + update=extend_schema(tags=['System']), + partial_update=extend_schema(tags=['System']), +) +class ModuleEnableSettingsViewSet(AccountModelViewSet): + """ + ViewSet for managing module enable/disable settings + Unified API Standard v1.0 compliant + One record per account + """ + queryset = ModuleEnableSettings.objects.all() + serializer_class = ModuleEnableSettingsSerializer + permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] + authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] + + def get_queryset(self): + """Get module enable settings for current account""" + queryset = super().get_queryset() + return queryset + + def list(self, request): + """Get or create module enable settings for current account""" + account = getattr(request, 'account', None) + if not account: + user = getattr(request, 'user', None) + if user: + account = getattr(user, 'account', None) + + if not account: + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get or create settings for account + settings = ModuleEnableSettings.get_or_create_for_account(account) + serializer = self.get_serializer(settings) + return success_response(data=serializer.data, request=request) + + def retrieve(self, request, pk=None): + """Get module enable settings for current account""" + account = getattr(request, 'account', None) + if not account: + user = getattr(request, 'user', None) + if user: + account = getattr(user, 'account', None) + + if not account: + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get or create settings for account + settings = ModuleEnableSettings.get_or_create_for_account(account) + serializer = self.get_serializer(settings) + return success_response(data=serializer.data, request=request) + + def update(self, request, pk=None): + """Update module enable settings for current account""" + account = getattr(request, 'account', None) + if not account: + user = getattr(request, 'user', None) + if user: + account = getattr(user, 'account', None) + + if not account: + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get or create settings for account + settings = ModuleEnableSettings.get_or_create_for_account(account) + serializer = self.get_serializer(settings, data=request.data, partial=True) + + if serializer.is_valid(): + serializer.save() + return success_response(data=serializer.data, request=request) + + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + def partial_update(self, request, pk=None): + """Partial update module enable settings""" + return self.update(request, pk) + + @extend_schema_view( list=extend_schema(tags=['System']), create=extend_schema(tags=['System']), diff --git a/backend/igny8_core/modules/system/urls.py b/backend/igny8_core/modules/system/urls.py index 5e19b49c..c03c49b3 100644 --- a/backend/igny8_core/modules/system/urls.py +++ b/backend/igny8_core/modules/system/urls.py @@ -7,7 +7,7 @@ from .views import AIPromptViewSet, AuthorProfileViewSet, StrategyViewSet, syste from .integration_views import IntegrationSettingsViewSet from .settings_views import ( SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet, - ModuleSettingsViewSet, AISettingsViewSet + ModuleSettingsViewSet, ModuleEnableSettingsViewSet, AISettingsViewSet ) router = DefaultRouter() router.register(r'prompts', AIPromptViewSet, basename='prompts') @@ -17,6 +17,7 @@ router.register(r'settings/system', SystemSettingsViewSet, basename='system-sett router.register(r'settings/account', AccountSettingsViewSet, basename='account-settings') router.register(r'settings/user', UserSettingsViewSet, basename='user-settings') router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings') +router.register(r'settings/modules/enable', ModuleEnableSettingsViewSet, basename='module-enable-settings') router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings') # Custom URL patterns for integration settings - matching reference plugin structure From 8102aa74eb265fd8dbb1fbe8cde056b84c336507 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:43:24 +0000 Subject: [PATCH 04/35] Phase 0: Add frontend module config and update settings store - Created modules.config.ts with module definitions - Added ModuleEnableSettings API functions - Updated settingsStore with module enable settings support - Added isModuleEnabled helper method --- frontend/src/config/modules.config.ts | 110 ++++++++++++++++++++++++++ frontend/src/services/api.ts | 27 +++++++ frontend/src/store/settingsStore.ts | 39 +++++++++ 3 files changed, 176 insertions(+) create mode 100644 frontend/src/config/modules.config.ts diff --git a/frontend/src/config/modules.config.ts b/frontend/src/config/modules.config.ts new file mode 100644 index 00000000..45d7440d --- /dev/null +++ b/frontend/src/config/modules.config.ts @@ -0,0 +1,110 @@ +/** + * Module Configuration + * Defines all available modules and their properties + */ + +export interface ModuleConfig { + name: string; + route: string; + icon?: string; + description?: string; + enabled: boolean; // Will be checked from API +} + +export const MODULES: Record = { + planner: { + name: 'Planner', + route: '/planner', + icon: '📊', + description: 'Keyword research and clustering', + enabled: true, // Default, will be checked from API + }, + writer: { + name: 'Writer', + route: '/writer', + icon: '✍️', + description: 'Content generation and management', + enabled: true, + }, + thinker: { + name: 'Thinker', + route: '/thinker', + icon: '🧠', + description: 'AI prompts and strategies', + enabled: true, + }, + automation: { + name: 'Automation', + route: '/automation', + icon: '⚙️', + description: 'Automated workflows and tasks', + enabled: true, + }, + site_builder: { + name: 'Site Builder', + route: '/site-builder', + icon: '🏗️', + description: 'Build and manage sites', + enabled: true, + }, + linker: { + name: 'Linker', + route: '/linker', + icon: '🔗', + description: 'Internal linking optimization', + enabled: true, + }, + optimizer: { + name: 'Optimizer', + route: '/optimizer', + icon: '⚡', + description: 'Content optimization', + enabled: true, + }, + publisher: { + name: 'Publisher', + route: '/publisher', + icon: '📤', + description: 'Content publishing', + enabled: true, + }, +}; + +/** + * Get module config by name + */ +export function getModuleConfig(moduleName: string): ModuleConfig | undefined { + return MODULES[moduleName]; +} + +/** + * Get all enabled modules + */ +export function getEnabledModules(moduleEnableSettings?: Record): ModuleConfig[] { + return Object.entries(MODULES) + .filter(([key, module]) => { + // If moduleEnableSettings provided, use it; otherwise default to enabled + if (moduleEnableSettings) { + const enabledKey = `${key}_enabled` as keyof typeof moduleEnableSettings; + return moduleEnableSettings[enabledKey] !== false; // Default to true if not set + } + return module.enabled; + }) + .map(([, module]) => module); +} + +/** + * Check if a module is enabled + */ +export function isModuleEnabled(moduleName: string, moduleEnableSettings?: Record): boolean { + const module = MODULES[moduleName]; + if (!module) return false; + + if (moduleEnableSettings) { + const enabledKey = `${moduleName}_enabled` as keyof typeof moduleEnableSettings; + return moduleEnableSettings[enabledKey] !== false; // Default to true if not set + } + + return module.enabled; +} + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index dbb1a98b..566bf003 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1474,6 +1474,20 @@ export async function deleteAccountSetting(key: string): Promise { } // Module Settings +export interface ModuleEnableSettings { + id: number; + planner_enabled: boolean; + writer_enabled: boolean; + thinker_enabled: boolean; + automation_enabled: boolean; + site_builder_enabled: boolean; + linker_enabled: boolean; + optimizer_enabled: boolean; + publisher_enabled: boolean; + created_at: string; + updated_at: string; +} + export interface ModuleSetting { id: number; module_name: string; @@ -1498,6 +1512,19 @@ export async function createModuleSetting(data: { module_name: string; key: stri }); } +export async function fetchModuleEnableSettings(): Promise { + const response = await fetchAPI('/v1/system/settings/modules/enable/'); + return response; +} + +export async function updateModuleEnableSettings(data: Partial): Promise { + const response = await fetchAPI('/v1/system/settings/modules/enable/', { + method: 'PUT', + body: JSON.stringify(data), + }); + return response; +} + export async function updateModuleSetting(moduleName: string, key: string, data: Partial<{ config: Record; is_active: boolean }>): Promise { return fetchAPI(`/v1/system/settings/modules/${key}/?module_name=${moduleName}`, { method: 'PUT', diff --git a/frontend/src/store/settingsStore.ts b/frontend/src/store/settingsStore.ts index 76f66ef6..453d9f74 100644 --- a/frontend/src/store/settingsStore.ts +++ b/frontend/src/store/settingsStore.ts @@ -13,13 +13,17 @@ import { fetchModuleSettings, createModuleSetting, updateModuleSetting, + fetchModuleEnableSettings, + updateModuleEnableSettings, AccountSetting, ModuleSetting, + ModuleEnableSettings, } from '../services/api'; interface SettingsState { accountSettings: Record; moduleSettings: Record>; + moduleEnableSettings: ModuleEnableSettings | null; loading: boolean; error: string | null; @@ -29,6 +33,9 @@ interface SettingsState { updateAccountSetting: (key: string, value: any) => Promise; loadModuleSettings: (moduleName: string) => Promise; updateModuleSetting: (moduleName: string, key: string, value: any) => Promise; + loadModuleEnableSettings: () => Promise; + updateModuleEnableSettings: (data: Partial) => Promise; + isModuleEnabled: (moduleName: string) => boolean; reset: () => void; } @@ -37,6 +44,7 @@ export const useSettingsStore = create()( (set, get) => ({ accountSettings: {}, moduleSettings: {}, + moduleEnableSettings: null, loading: false, error: null, @@ -135,10 +143,40 @@ export const useSettingsStore = create()( } }, + loadModuleEnableSettings: async () => { + set({ loading: true, error: null }); + try { + const settings = await fetchModuleEnableSettings(); + set({ moduleEnableSettings: settings, loading: false }); + } catch (error: any) { + set({ error: error.message, loading: false }); + } + }, + + updateModuleEnableSettings: async (data: Partial) => { + set({ loading: true, error: null }); + try { + const settings = await updateModuleEnableSettings(data); + set({ moduleEnableSettings: settings, loading: false }); + } catch (error: any) { + set({ error: error.message, loading: false }); + throw error; + } + }, + + isModuleEnabled: (moduleName: string): boolean => { + const settings = get().moduleEnableSettings; + if (!settings) return true; // Default to enabled if not loaded + + const enabledKey = `${moduleName}_enabled` as keyof ModuleEnableSettings; + return settings[enabledKey] !== false; // Default to true if not set + }, + reset: () => { set({ accountSettings: {}, moduleSettings: {}, + moduleEnableSettings: null, loading: false, error: null, }); @@ -149,6 +187,7 @@ export const useSettingsStore = create()( partialize: (state) => ({ accountSettings: state.accountSettings, moduleSettings: state.moduleSettings, + moduleEnableSettings: state.moduleEnableSettings, }), } ) From dbe8da589ffb4c62cc80b49607ea564b20ebf831 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:44:07 +0000 Subject: [PATCH 05/35] Phase 0: Add ModuleGuard component and implement Modules settings UI - Created ModuleGuard component to protect routes based on module status - Implemented Modules.tsx page with toggle switches for all modules - Fixed Switch component onChange prop type - Module enable/disable UI fully functional --- .../src/components/common/ModuleGuard.tsx | 40 ++++++++++ frontend/src/pages/Settings/Modules.tsx | 73 +++++++++++++++---- 2 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/common/ModuleGuard.tsx diff --git a/frontend/src/components/common/ModuleGuard.tsx b/frontend/src/components/common/ModuleGuard.tsx new file mode 100644 index 00000000..02bd0ab5 --- /dev/null +++ b/frontend/src/components/common/ModuleGuard.tsx @@ -0,0 +1,40 @@ +import { ReactNode, useEffect } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useSettingsStore } from '../../store/settingsStore'; +import { isModuleEnabled } from '../../config/modules.config'; + +interface ModuleGuardProps { + module: string; + children: ReactNode; + redirectTo?: string; +} + +/** + * ModuleGuard - Protects routes based on module enable status + * Redirects to settings page if module is disabled + */ +export default function ModuleGuard({ module, children, redirectTo = '/settings/modules' }: ModuleGuardProps) { + const { moduleEnableSettings, loadModuleEnableSettings, loading } = useSettingsStore(); + + useEffect(() => { + // Load module enable settings if not already loaded + if (!moduleEnableSettings && !loading) { + loadModuleEnableSettings(); + } + }, [moduleEnableSettings, loading, loadModuleEnableSettings]); + + // While loading, show children (optimistic rendering) + if (loading || !moduleEnableSettings) { + return <>{children}; + } + + // Check if module is enabled + const enabled = isModuleEnabled(module, moduleEnableSettings as any); + + if (!enabled) { + return ; + } + + return <>{children}; +} + diff --git a/frontend/src/pages/Settings/Modules.tsx b/frontend/src/pages/Settings/Modules.tsx index 16276b8a..7952be3b 100644 --- a/frontend/src/pages/Settings/Modules.tsx +++ b/frontend/src/pages/Settings/Modules.tsx @@ -1,36 +1,48 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import PageMeta from '../../components/common/PageMeta'; import { useToast } from '../../components/ui/toast/ToastContainer'; -import { fetchAPI } from '../../services/api'; +import { useSettingsStore } from '../../store/settingsStore'; +import { MODULES } from '../../config/modules.config'; import { Card } from '../../components/ui/card'; +import Switch from '../../components/form/switch/Switch'; export default function ModuleSettings() { const toast = useToast(); - const [settings, setSettings] = useState([]); - const [loading, setLoading] = useState(true); + const { + moduleEnableSettings, + loadModuleEnableSettings, + updateModuleEnableSettings, + loading, + } = useSettingsStore(); useEffect(() => { - loadSettings(); - }, []); + loadModuleEnableSettings(); + }, [loadModuleEnableSettings]); - const loadSettings = async () => { + const handleToggle = async (moduleName: string, enabled: boolean) => { try { - setLoading(true); - const response = await fetchAPI('/v1/system/settings/modules/'); - setSettings(response.results || []); + const enabledKey = `${moduleName}_enabled` as keyof typeof moduleEnableSettings; + await updateModuleEnableSettings({ + [enabledKey]: enabled, + } as any); + toast.success(`${MODULES[moduleName]?.name || moduleName} ${enabled ? 'enabled' : 'disabled'}`); } catch (error: any) { - toast.error(`Failed to load module settings: ${error.message}`); - } finally { - setLoading(false); + toast.error(`Failed to update module: ${error.message}`); } }; + const getModuleEnabled = (moduleName: string): boolean => { + if (!moduleEnableSettings) return true; // Default to enabled + const enabledKey = `${moduleName}_enabled` as keyof typeof moduleEnableSettings; + return moduleEnableSettings[enabledKey] !== false; + }; + return (

Module Settings

-

Module-specific configuration

+

Enable or disable modules for your account

{loading ? ( @@ -39,7 +51,38 @@ export default function ModuleSettings() {
) : ( -

Module settings management interface coming soon.

+
+ {Object.entries(MODULES).map(([key, module]) => ( +
+
+
{module.icon}
+
+

+ {module.name} +

+ {module.description && ( +

+ {module.description} +

+ )} +
+
+
+ + {getModuleEnabled(key) ? 'Enabled' : 'Disabled'} + + handleToggle(key, enabled)} + /> +
+
+ ))} +
)} From 9b3fb25bc9772a3e954c15de1e989b3b06fc2631 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:48:23 +0000 Subject: [PATCH 06/35] Phase 0: Add sidebar filtering and route guards for modules - Updated AppSidebar to filter out disabled modules from navigation - Added ModuleGuard to all module routes (planner, writer, thinker, automation) - Modules now dynamically appear/disappear based on enable settings - Routes are protected and redirect to settings if module is disabled --- frontend/src/App.tsx | 69 +++++++++--- frontend/src/layout/AppSidebar.tsx | 175 +++++++++++++++++------------ 2 files changed, 155 insertions(+), 89 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 86d94459..0d09dbb0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { HelmetProvider } from "react-helmet-async"; import AppLayout from "./layout/AppLayout"; import { ScrollToTop } from "./components/common/ScrollToTop"; import ProtectedRoute from "./components/auth/ProtectedRoute"; +import ModuleGuard from "./components/common/ModuleGuard"; import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay"; import LoadingStateMonitor from "./components/common/LoadingStateMonitor"; @@ -133,90 +134,122 @@ export default function App() { {/* Planner Module */} - + + + } /> - + + + } /> - + + + } /> - + + + } /> {/* Writer Module */} + - + + } /> + - + + } /> {/* Writer Content Routes - Order matters: list route must come before detail route */} + - + + } /> {/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */} + - + + } /> } /> + - + + } /> + - + + } /> {/* Thinker Module */} + - + + } /> + - + + } /> + - + + } /> + - + + } /> + - + + } /> + - + + } /> {/* Billing Module */} @@ -256,8 +289,10 @@ export default function App() { {/* Other Pages */} + - + + } /> diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 10f3bee0..549fe0c3 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -20,6 +20,8 @@ import { useSidebar } from "../context/SidebarContext"; import SidebarWidget from "./SidebarWidget"; import { APP_VERSION } from "../config/version"; import { useAuthStore } from "../store/authStore"; +import { useSettingsStore } from "../store/settingsStore"; +import { isModuleEnabled } from "../config/modules.config"; import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator"; type NavItem = { @@ -38,12 +40,19 @@ const AppSidebar: React.FC = () => { const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar(); const location = useLocation(); const { user } = useAuthStore(); + const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled } = useSettingsStore(); // Show admin menu only for users in aws-admin account const isAwsAdminAccount = Boolean( user?.account?.slug === 'aws-admin' || user?.role === 'developer' // Also show for developers as fallback ); + + // Helper to check if module is enabled + const moduleEnabled = (moduleName: string): boolean => { + if (!moduleEnableSettings) return true; // Default to enabled if not loaded + return checkModuleEnabled(moduleName); + }; const [openSubmenu, setOpenSubmenu] = useState<{ sectionIndex: number; @@ -60,77 +69,98 @@ const AppSidebar: React.FC = () => { ); // Define menu sections with useMemo to prevent recreation on every render - const menuSections: MenuSection[] = useMemo(() => [ - { - label: "OVERVIEW", - items: [ - { - icon: , - name: "Dashboard", - path: "/", - }, - { - icon: , - name: "Industry / Sectors", - path: "/reference/industries", - }, - ], - }, - { - label: "WORKFLOWS", - items: [ - { - icon: , - name: "Setup", - subItems: [ - { name: "Sites", path: "/settings/sites" }, - { name: "Keywords Opportunities", path: "/planner/keyword-opportunities" }, - ], - }, - { - icon: , - name: "Planner", - subItems: [ - { name: "Dashboard", path: "/planner" }, - { name: "Keywords", path: "/planner/keywords" }, - { name: "Clusters", path: "/planner/clusters" }, - { name: "Ideas", path: "/planner/ideas" }, - ], - }, - { - icon: , - name: "Writer", - subItems: [ - { name: "Dashboard", path: "/writer" }, - { name: "Tasks", path: "/writer/tasks" }, - { name: "Content", path: "/writer/content" }, - { name: "Images", path: "/writer/images" }, - { name: "Published", path: "/writer/published" }, - ], - }, - { - icon: , - name: "Thinker", - subItems: [ - { name: "Dashboard", path: "/thinker" }, - { name: "Prompts", path: "/thinker/prompts" }, - { name: "Author Profiles", path: "/thinker/author-profiles" }, - { name: "Strategies", path: "/thinker/strategies" }, - { name: "Image Testing", path: "/thinker/image-testing" }, - ], - }, - { - icon: , - name: "Automation", - path: "/automation", - }, - { - icon: , - name: "Schedules", - path: "/schedules", - }, - ], - }, + // Filter out disabled modules based on module enable settings + const menuSections: MenuSection[] = useMemo(() => { + const workflowItems: NavItem[] = [ + { + icon: , + name: "Setup", + subItems: [ + { name: "Sites", path: "/settings/sites" }, + { name: "Keywords Opportunities", path: "/planner/keyword-opportunities" }, + ], + }, + ]; + + // Add Planner if enabled + if (moduleEnabled('planner')) { + workflowItems.push({ + icon: , + name: "Planner", + subItems: [ + { name: "Dashboard", path: "/planner" }, + { name: "Keywords", path: "/planner/keywords" }, + { name: "Clusters", path: "/planner/clusters" }, + { name: "Ideas", path: "/planner/ideas" }, + ], + }); + } + + // Add Writer if enabled + if (moduleEnabled('writer')) { + workflowItems.push({ + icon: , + name: "Writer", + subItems: [ + { name: "Dashboard", path: "/writer" }, + { name: "Tasks", path: "/writer/tasks" }, + { name: "Content", path: "/writer/content" }, + { name: "Images", path: "/writer/images" }, + { name: "Published", path: "/writer/published" }, + ], + }); + } + + // Add Thinker if enabled + if (moduleEnabled('thinker')) { + workflowItems.push({ + icon: , + name: "Thinker", + subItems: [ + { name: "Dashboard", path: "/thinker" }, + { name: "Prompts", path: "/thinker/prompts" }, + { name: "Author Profiles", path: "/thinker/author-profiles" }, + { name: "Strategies", path: "/thinker/strategies" }, + { name: "Image Testing", path: "/thinker/image-testing" }, + ], + }); + } + + // Add Automation if enabled + if (moduleEnabled('automation')) { + workflowItems.push({ + icon: , + name: "Automation", + path: "/automation", + }); + } + + workflowItems.push({ + icon: , + name: "Schedules", + path: "/schedules", + }); + + return [ + { + label: "OVERVIEW", + items: [ + { + icon: , + name: "Dashboard", + path: "/", + }, + { + icon: , + name: "Industry / Sectors", + path: "/reference/industries", + }, + ], + }, + { + label: "WORKFLOWS", + items: workflowItems, + }, { label: "ACCOUNT & SETTINGS", items: [ @@ -165,7 +195,8 @@ const AppSidebar: React.FC = () => { }, ], }, - ], []); + ]; + }, [moduleEnableSettings, moduleEnabled]); // Admin section - only shown for users in aws-admin account const adminSection: MenuSection = useMemo(() => ({ From 5842ca2dfc3e674e7db9fd0cb7d41e8789755f6a Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:48:45 +0000 Subject: [PATCH 07/35] Phase 0: Fix AppSidebar useEffect for module settings loading --- frontend/src/layout/AppSidebar.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 549fe0c3..c2fadeb4 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -282,6 +282,14 @@ const AppSidebar: React.FC = () => { : menuSections; }, [isAwsAdminAccount, menuSections, adminSection]); + // Load module enable settings on mount + useEffect(() => { + const { loadModuleEnableSettings } = useSettingsStore.getState(); + if (!moduleEnableSettings) { + loadModuleEnableSettings(); + } + }, [moduleEnableSettings]); + useEffect(() => { const currentPath = location.pathname; let foundMatch = false; From a10e89ab08724c5db4a982dae71103248ea6c2dd Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:50:24 +0000 Subject: [PATCH 08/35] Phase 0: Remove plan operation limit fields (credit-only system) - Removed all operation limit fields from Plan model - Kept account management limits (max_users, max_sites, etc.) - Updated PlanSerializer to remove limit fields - Updated PlanAdmin to remove limit fieldsets - Created migration to remove limit fields from database - Plan model now only has credits, billing, and account management fields --- backend/igny8_core/auth/admin.py | 14 +-- ...014_remove_plan_operation_limits_phase0.py | 86 +++++++++++++++++++ backend/igny8_core/auth/models.py | 33 +------ backend/igny8_core/auth/serializers.py | 8 +- 4 files changed, 95 insertions(+), 46 deletions(-) create mode 100644 backend/igny8_core/auth/migrations/0014_remove_plan_operation_limits_phase0.py diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py index 3f208a45..0f134bf2 100644 --- a/backend/igny8_core/auth/admin.py +++ b/backend/igny8_core/auth/admin.py @@ -19,21 +19,9 @@ class PlanAdmin(admin.ModelAdmin): ('Plan Info', { 'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active') }), - ('User / Site Limits', { + ('Account Management Limits', { 'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles') }), - ('Planner Limits', { - 'fields': ('max_keywords', 'max_clusters', 'daily_cluster_limit', 'daily_keyword_import_limit', 'monthly_cluster_ai_credits') - }), - ('Writer Limits', { - 'fields': ('daily_content_tasks', 'daily_ai_requests', 'monthly_word_count_limit', 'monthly_content_ai_credits') - }), - ('Image Limits', { - 'fields': ('monthly_image_count', 'monthly_image_ai_credits', 'max_images_per_task', 'image_model_choices') - }), - ('AI Controls', { - 'fields': ('daily_ai_request_limit', 'monthly_ai_credit_limit') - }), ('Billing & Credits', { 'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month') }), diff --git a/backend/igny8_core/auth/migrations/0014_remove_plan_operation_limits_phase0.py b/backend/igny8_core/auth/migrations/0014_remove_plan_operation_limits_phase0.py new file mode 100644 index 00000000..8c17db4d --- /dev/null +++ b/backend/igny8_core/auth/migrations/0014_remove_plan_operation_limits_phase0.py @@ -0,0 +1,86 @@ +# Generated manually for Phase 0: Remove plan operation limit fields (credit-only system) + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_auth', '0013_remove_ai_cost_per_request'), + ] + + operations = [ + # Remove Planner Limits + migrations.RemoveField( + model_name='plan', + name='max_keywords', + ), + migrations.RemoveField( + model_name='plan', + name='max_clusters', + ), + migrations.RemoveField( + model_name='plan', + name='max_content_ideas', + ), + migrations.RemoveField( + model_name='plan', + name='daily_cluster_limit', + ), + migrations.RemoveField( + model_name='plan', + name='daily_keyword_import_limit', + ), + migrations.RemoveField( + model_name='plan', + name='monthly_cluster_ai_credits', + ), + # Remove Writer Limits + migrations.RemoveField( + model_name='plan', + name='daily_content_tasks', + ), + migrations.RemoveField( + model_name='plan', + name='daily_ai_requests', + ), + migrations.RemoveField( + model_name='plan', + name='monthly_word_count_limit', + ), + migrations.RemoveField( + model_name='plan', + name='monthly_content_ai_credits', + ), + # Remove Image Generation Limits + migrations.RemoveField( + model_name='plan', + name='monthly_image_count', + ), + migrations.RemoveField( + model_name='plan', + name='daily_image_generation_limit', + ), + migrations.RemoveField( + model_name='plan', + name='monthly_image_ai_credits', + ), + migrations.RemoveField( + model_name='plan', + name='max_images_per_task', + ), + migrations.RemoveField( + model_name='plan', + name='image_model_choices', + ), + # Remove AI Request Controls + migrations.RemoveField( + model_name='plan', + name='daily_ai_request_limit', + ), + migrations.RemoveField( + model_name='plan', + name='monthly_ai_credit_limit', + ), + ] + diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index 8afc49bb..4bc11a86 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -93,8 +93,8 @@ class Account(models.Model): class Plan(models.Model): """ - Subscription plan model with comprehensive limits and features. - Plans define limits for users, sites, content generation, AI usage, and billing. + Subscription plan model - Phase 0: Credit-only system. + Plans define credits, billing, and account management limits only. """ BILLING_CYCLE_CHOICES = [ ('monthly', 'Monthly'), @@ -110,7 +110,7 @@ class Plan(models.Model): is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) - # User / Site / Scope Limits + # Account Management Limits (kept - not operation limits) max_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account") max_sites = models.IntegerField( default=1, @@ -120,32 +120,7 @@ class Plan(models.Model): max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors") max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles") - # Planner Limits - max_keywords = models.IntegerField(default=1000, validators=[MinValueValidator(0)], help_text="Total keywords allowed (global limit)") - max_clusters = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Total clusters allowed (global)") - max_content_ideas = models.IntegerField(default=300, validators=[MinValueValidator(0)], help_text="Total content ideas allowed (global limit)") - daily_cluster_limit = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max clusters that can be created per day") - daily_keyword_import_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="SeedKeywords import limit per day") - monthly_cluster_ai_credits = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="AI credits allocated for clustering") - - # Writer Limits - daily_content_tasks = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max number of content tasks (blogs) per day") - daily_ai_requests = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="Total AI executions (content + idea + image) allowed per day") - monthly_word_count_limit = models.IntegerField(default=50000, validators=[MinValueValidator(0)], help_text="Monthly word limit (for generated content)") - monthly_content_ai_credits = models.IntegerField(default=200, validators=[MinValueValidator(0)], help_text="AI credit pool for content generation") - - # Image Generation Limits - monthly_image_count = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Max images per month") - daily_image_generation_limit = models.IntegerField(default=25, validators=[MinValueValidator(0)], help_text="Max images that can be generated per day") - monthly_image_ai_credits = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="AI credit pool for image generation") - max_images_per_task = models.IntegerField(default=4, validators=[MinValueValidator(1)], help_text="Max images per content task") - image_model_choices = models.JSONField(default=list, blank=True, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])") - - # AI Request Controls - daily_ai_request_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Global daily AI request cap") - monthly_ai_credit_limit = models.IntegerField(default=500, validators=[MinValueValidator(0)], help_text="Unified credit ceiling per month (all AI functions)") - - # Billing & Add-ons + # Billing & Credits (Phase 0: Credit-only system) included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included") extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit") allow_credit_topup = models.BooleanField(default=True, help_text="Can user purchase more credits?") diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index 4b71a109..b8d4af2c 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -11,10 +11,10 @@ class PlanSerializer(serializers.ModelSerializer): model = Plan fields = [ 'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active', - 'max_users', 'max_sites', 'max_keywords', 'max_clusters', 'max_content_ideas', - 'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count', - 'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit', - 'included_credits', 'image_model_choices', 'credits_per_month' + 'max_users', 'max_sites', 'max_industries', 'max_author_profiles', + 'included_credits', 'extra_credit_price', 'allow_credit_topup', + 'auto_credit_topup_threshold', 'auto_credit_topup_amount', + 'stripe_product_id', 'stripe_price_id', 'credits_per_month' ] From abbf6dbabbdeaef6ce58268115447910c1a32ebe Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:51:40 +0000 Subject: [PATCH 09/35] Phase 0: Remove plan limit checks from billing views - Updated limits endpoint to show only credits and account management limits - Removed all operation limit references (keywords, clusters, content ideas, word count, images) - Limits endpoint now focuses on credit usage by operation type - Account management limits (users, sites) still shown --- backend/igny8_core/modules/billing/views.py | 195 ++++++-------------- 1 file changed, 59 insertions(+), 136 deletions(-) diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index 0393342c..a8b31973 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -207,7 +207,10 @@ class CreditUsageViewSet(AccountModelViewSet): @action(detail=False, methods=['get'], url_path='limits', url_name='limits') def limits(self, request): - """Get plan limits and current usage statistics""" + """ + Get account limits and credit usage statistics (Phase 0: Credit-only system). + Returns account management limits and credit usage only. + """ # Try multiple ways to get account account = getattr(request, 'account', None) @@ -225,13 +228,7 @@ class CreditUsageViewSet(AccountModelViewSet): except (AttributeError, UserModel.DoesNotExist, Exception) as e: account = None - # Debug logging - import logging - logger = logging.getLogger(__name__) - logger.info(f'Limits endpoint - User: {getattr(request, "user", None)}, Account: {account}, Account has plan: {account.plan if account else False}') - if not account: - logger.warning(f'No account found in limits endpoint') # Return empty limits instead of error - frontend will show "no data" message return success_response(data={'limits': []}, request=request) @@ -241,115 +238,16 @@ class CreditUsageViewSet(AccountModelViewSet): return success_response(data={'limits': []}, request=request) # Import models - from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas - from igny8_core.modules.writer.models import Tasks, Images from igny8_core.auth.models import User, Site # Get current month boundaries now = timezone.now() start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) # Calculate usage statistics limits_data = [] - # Planner Limits - keywords_count = Keywords.objects.filter(account=account).count() - clusters_count = Clusters.objects.filter(account=account).count() - content_ideas_count = ContentIdeas.objects.filter(account=account).count() - clusters_today = Clusters.objects.filter(account=account, created_at__gte=start_of_day).count() - - limits_data.extend([ - { - 'title': 'Keywords', - 'limit': plan.max_keywords or 0, - 'used': keywords_count, - 'available': max(0, (plan.max_keywords or 0) - keywords_count), - 'unit': 'keywords', - 'category': 'planner', - 'percentage': (keywords_count / (plan.max_keywords or 1)) * 100 if plan.max_keywords else 0 - }, - { - 'title': 'Clusters', - 'limit': plan.max_clusters or 0, - 'used': clusters_count, - 'available': max(0, (plan.max_clusters or 0) - clusters_count), - 'unit': 'clusters', - 'category': 'planner', - 'percentage': (clusters_count / (plan.max_clusters or 1)) * 100 if plan.max_clusters else 0 - }, - { - 'title': 'Content Ideas', - 'limit': plan.max_content_ideas or 0, - 'used': content_ideas_count, - 'available': max(0, (plan.max_content_ideas or 0) - content_ideas_count), - 'unit': 'ideas', - 'category': 'planner', - 'percentage': (content_ideas_count / (plan.max_content_ideas or 1)) * 100 if plan.max_content_ideas else 0 - }, - { - 'title': 'Daily Cluster Limit', - 'limit': plan.daily_cluster_limit or 0, - 'used': clusters_today, - 'available': max(0, (plan.daily_cluster_limit or 0) - clusters_today), - 'unit': 'per day', - 'category': 'planner', - 'percentage': (clusters_today / (plan.daily_cluster_limit or 1)) * 100 if plan.daily_cluster_limit else 0 - }, - ]) - - # Writer Limits - tasks_today = Tasks.objects.filter(account=account, created_at__gte=start_of_day).count() - tasks_month = Tasks.objects.filter(account=account, created_at__gte=start_of_month) - word_count_month = tasks_month.aggregate(total=Sum('word_count'))['total'] or 0 - - limits_data.extend([ - { - 'title': 'Monthly Word Count', - 'limit': plan.monthly_word_count_limit or 0, - 'used': word_count_month, - 'available': max(0, (plan.monthly_word_count_limit or 0) - word_count_month), - 'unit': 'words', - 'category': 'writer', - 'percentage': (word_count_month / (plan.monthly_word_count_limit or 1)) * 100 if plan.monthly_word_count_limit else 0 - }, - { - 'title': 'Daily Content Tasks', - 'limit': plan.daily_content_tasks or 0, - 'used': tasks_today, - 'available': max(0, (plan.daily_content_tasks or 0) - tasks_today), - 'unit': 'per day', - 'category': 'writer', - 'percentage': (tasks_today / (plan.daily_content_tasks or 1)) * 100 if plan.daily_content_tasks else 0 - }, - ]) - - # Image Limits - images_month = Images.objects.filter(account=account, created_at__gte=start_of_month).count() - images_today = Images.objects.filter(account=account, created_at__gte=start_of_day).count() - - limits_data.extend([ - { - 'title': 'Monthly Images', - 'limit': plan.monthly_image_count or 0, - 'used': images_month, - 'available': max(0, (plan.monthly_image_count or 0) - images_month), - 'unit': 'images', - 'category': 'images', - 'percentage': (images_month / (plan.monthly_image_count or 1)) * 100 if plan.monthly_image_count else 0 - }, - { - 'title': 'Daily Image Generation', - 'limit': plan.daily_image_generation_limit or 0, - 'used': images_today, - 'available': max(0, (plan.daily_image_generation_limit or 0) - images_today), - 'unit': 'per day', - 'category': 'images', - 'percentage': (images_today / (plan.daily_image_generation_limit or 1)) * 100 if plan.daily_image_generation_limit else 0 - }, - ]) - - # AI Credits + # Credit Usage (Phase 0: Credit-only system) credits_used_month = CreditUsageLog.objects.filter( account=account, created_at__gte=start_of_month @@ -358,64 +256,89 @@ class CreditUsageViewSet(AccountModelViewSet): # Get credits by operation type cluster_credits = CreditUsageLog.objects.filter( account=account, - operation_type='clustering', + operation_type__in=['clustering'], created_at__gte=start_of_month ).aggregate(total=Sum('credits_used'))['total'] or 0 content_credits = CreditUsageLog.objects.filter( account=account, - operation_type='content', + operation_type__in=['content', 'content_generation'], created_at__gte=start_of_month ).aggregate(total=Sum('credits_used'))['total'] or 0 image_credits = CreditUsageLog.objects.filter( account=account, - operation_type='image', + operation_type__in=['images', 'image_generation', 'image_prompt_extraction'], created_at__gte=start_of_month ).aggregate(total=Sum('credits_used'))['total'] or 0 - plan_credits = plan.monthly_ai_credit_limit or plan.credits_per_month or 0 + idea_credits = CreditUsageLog.objects.filter( + account=account, + operation_type__in=['ideas', 'idea_generation'], + created_at__gte=start_of_month + ).aggregate(total=Sum('credits_used'))['total'] or 0 + + # Use included_credits from plan (Phase 0: Credit-only) + plan_credits = plan.included_credits or plan.credits_per_month or 0 limits_data.extend([ { - 'title': 'Monthly AI Credits', + 'title': 'Monthly Credits', 'limit': plan_credits, 'used': credits_used_month, 'available': max(0, plan_credits - credits_used_month), 'unit': 'credits', - 'category': 'ai', + 'category': 'credits', 'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0 }, { - 'title': 'Content AI Credits', - 'limit': plan.monthly_content_ai_credits or 0, - 'used': content_credits, - 'available': max(0, (plan.monthly_content_ai_credits or 0) - content_credits), + 'title': 'Current Balance', + 'limit': None, # No limit - shows current balance + 'used': None, + 'available': account.credits, 'unit': 'credits', - 'category': 'ai', - 'percentage': (content_credits / (plan.monthly_content_ai_credits or 1)) * 100 if plan.monthly_content_ai_credits else 0 + 'category': 'credits', + 'percentage': None }, { - 'title': 'Image AI Credits', - 'limit': plan.monthly_image_ai_credits or 0, - 'used': image_credits, - 'available': max(0, (plan.monthly_image_ai_credits or 0) - image_credits), - 'unit': 'credits', - 'category': 'ai', - 'percentage': (image_credits / (plan.monthly_image_ai_credits or 1)) * 100 if plan.monthly_image_ai_credits else 0 - }, - { - 'title': 'Cluster AI Credits', - 'limit': plan.monthly_cluster_ai_credits or 0, + 'title': 'Clustering Credits', + 'limit': None, 'used': cluster_credits, - 'available': max(0, (plan.monthly_cluster_ai_credits or 0) - cluster_credits), + 'available': None, 'unit': 'credits', - 'category': 'ai', - 'percentage': (cluster_credits / (plan.monthly_cluster_ai_credits or 1)) * 100 if plan.monthly_cluster_ai_credits else 0 + 'category': 'credits', + 'percentage': None + }, + { + 'title': 'Content Generation Credits', + 'limit': None, + 'used': content_credits, + 'available': None, + 'unit': 'credits', + 'category': 'credits', + 'percentage': None + }, + { + 'title': 'Image Generation Credits', + 'limit': None, + 'used': image_credits, + 'available': None, + 'unit': 'credits', + 'category': 'credits', + 'percentage': None + }, + { + 'title': 'Idea Generation Credits', + 'limit': None, + 'used': idea_credits, + 'available': None, + 'unit': 'credits', + 'category': 'credits', + 'percentage': None }, ]) - # General Limits + # Account Management Limits (kept - not operation limits) users_count = User.objects.filter(account=account).count() sites_count = Site.objects.filter(account=account).count() @@ -426,7 +349,7 @@ class CreditUsageViewSet(AccountModelViewSet): 'used': users_count, 'available': max(0, (plan.max_users or 0) - users_count), 'unit': 'users', - 'category': 'general', + 'category': 'account', 'percentage': (users_count / (plan.max_users or 1)) * 100 if plan.max_users else 0 }, { @@ -435,7 +358,7 @@ class CreditUsageViewSet(AccountModelViewSet): 'used': sites_count, 'available': max(0, (plan.max_sites or 0) - sites_count), 'unit': 'sites', - 'category': 'general', + 'category': 'account', 'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0 }, ]) From 461f3211dd247cdaf01f363f58a6138993792932 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:02:26 +0000 Subject: [PATCH 10/35] Phase 0: Add monthly credit replenishment Celery Beat task - Created billing/tasks.py with replenish_monthly_credits task - Task runs on first day of each month at midnight - Adds plan.included_credits to all active accounts - Creates CreditTransaction records for audit trail - Configured in celery.py beat_schedule - Handles errors gracefully and logs all operations --- backend/igny8_core/celery.py | 8 ++ backend/igny8_core/modules/billing/tasks.py | 99 +++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 backend/igny8_core/modules/billing/tasks.py diff --git a/backend/igny8_core/celery.py b/backend/igny8_core/celery.py index 7a869fa1..057a0e1d 100644 --- a/backend/igny8_core/celery.py +++ b/backend/igny8_core/celery.py @@ -3,6 +3,7 @@ Celery configuration for IGNY8 """ import os from celery import Celery +from celery.schedules import crontab # Set the default Django settings module for the 'celery' program. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings') @@ -18,6 +19,13 @@ app.config_from_object('django.conf:settings', namespace='CELERY') # Load task modules from all registered Django apps. app.autodiscover_tasks() +# Celery Beat schedule for periodic tasks +app.conf.beat_schedule = { + 'replenish-monthly-credits': { + 'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits', + 'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight + }, +} @app.task(bind=True, ignore_result=True) def debug_task(self): diff --git a/backend/igny8_core/modules/billing/tasks.py b/backend/igny8_core/modules/billing/tasks.py new file mode 100644 index 00000000..8b17f453 --- /dev/null +++ b/backend/igny8_core/modules/billing/tasks.py @@ -0,0 +1,99 @@ +""" +Celery tasks for billing operations +""" +import logging +from celery import shared_task +from django.utils import timezone +from django.db import transaction +from igny8_core.auth.models import Account +from .services import CreditService + +logger = logging.getLogger(__name__) + + +@shared_task(name='igny8_core.modules.billing.tasks.replenish_monthly_credits') +def replenish_monthly_credits(): + """ + Replenish monthly credits for all active accounts. + Runs on the first day of each month at midnight. + + For each active account with a plan: + - Adds plan.included_credits to account.credits + - Creates a CreditTransaction record + - Logs the replenishment + """ + logger.info("=" * 80) + logger.info("MONTHLY CREDIT REPLENISHMENT TASK STARTED") + logger.info(f"Timestamp: {timezone.now()}") + logger.info("=" * 80) + + # Get all active accounts with plans + accounts = Account.objects.filter( + status='active', + plan__isnull=False + ).select_related('plan') + + total_accounts = accounts.count() + logger.info(f"Found {total_accounts} active accounts with plans") + + replenished = 0 + skipped = 0 + errors = 0 + + for account in accounts: + try: + plan = account.plan + + # Get monthly credits from plan + monthly_credits = plan.included_credits or plan.credits_per_month or 0 + + if monthly_credits <= 0: + logger.info(f"Account {account.id} ({account.name}): Plan has no included credits, skipping") + skipped += 1 + continue + + # Add credits using CreditService + with transaction.atomic(): + new_balance = CreditService.add_credits( + account=account, + amount=monthly_credits, + transaction_type='subscription', + description=f"Monthly credit replenishment - {plan.name} plan", + metadata={ + 'plan_id': plan.id, + 'plan_name': plan.name, + 'monthly_credits': monthly_credits, + 'replenishment_date': timezone.now().isoformat() + } + ) + + logger.info( + f"Account {account.id} ({account.name}): " + f"Added {monthly_credits} credits (balance: {new_balance})" + ) + replenished += 1 + + except Exception as e: + logger.error( + f"Account {account.id} ({account.name}): " + f"Failed to replenish credits: {str(e)}", + exc_info=True + ) + errors += 1 + + logger.info("=" * 80) + logger.info("MONTHLY CREDIT REPLENISHMENT TASK COMPLETED") + logger.info(f"Total accounts: {total_accounts}") + logger.info(f"Replenished: {replenished}") + logger.info(f"Skipped: {skipped}") + logger.info(f"Errors: {errors}") + logger.info("=" * 80) + + return { + 'success': True, + 'total_accounts': total_accounts, + 'replenished': replenished, + 'skipped': skipped, + 'errors': errors + } + From d0e6b342b5d09d04f545c2b6f1f53db7dcd82050 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:06:07 +0000 Subject: [PATCH 11/35] Phase 0: Update billing pages to show credits and credit costs - Updated Usage page to show only credits and account management limits - Removed plan operation limit displays (planner, writer, images) - Added credit costs reference table showing cost per operation type - Updated limit cards to handle null limits (for current balance display) - Improved UI to focus on credit-only system --- frontend/src/pages/Billing/Usage.tsx | 210 +++++++++++++-------------- 1 file changed, 100 insertions(+), 110 deletions(-) diff --git a/frontend/src/pages/Billing/Usage.tsx b/frontend/src/pages/Billing/Usage.tsx index 64213568..1baa219a 100644 --- a/frontend/src/pages/Billing/Usage.tsx +++ b/frontend/src/pages/Billing/Usage.tsx @@ -5,6 +5,19 @@ import { fetchCreditUsage, CreditUsageLog, fetchUsageLimits, LimitCard } from '. import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; +// Credit costs per operation (Phase 0: Credit-only system) +const CREDIT_COSTS: Record = { + clustering: { cost: 10, description: 'Per clustering request' }, + idea_generation: { cost: 15, description: 'Per cluster → ideas request' }, + content_generation: { cost: '1 per 100 words', description: 'Per 100 words generated' }, + image_prompt_extraction: { cost: 2, description: 'Per content piece' }, + image_generation: { cost: 5, description: 'Per image generated' }, + linking: { cost: 8, description: 'Per content piece' }, + optimization: { cost: '1 per 200 words', description: 'Per 200 words optimized' }, + site_structure_generation: { cost: 50, description: 'Per site blueprint' }, + site_page_generation: { cost: 20, description: 'Per page generated' }, +}; + export default function Usage() { const toast = useToast(); const [usageLogs, setUsageLogs] = useState([]); @@ -33,13 +46,8 @@ export default function Usage() { try { setLimitsLoading(true); const response = await fetchUsageLimits(); - console.log('Usage limits response:', response); setLimits(response.limits || []); - if (!response.limits || response.limits.length === 0) { - console.warn('No limits data received from API'); - } } catch (error: any) { - console.error('Error loading usage limits:', error); toast.error(`Failed to load usage limits: ${error.message}`); setLimits([]); } finally { @@ -47,120 +55,82 @@ export default function Usage() { } }; - const groupedLimits = { - planner: limits.filter(l => l.category === 'planner'), - writer: limits.filter(l => l.category === 'writer'), - images: limits.filter(l => l.category === 'images'), - ai: limits.filter(l => l.category === 'ai'), - general: limits.filter(l => l.category === 'general'), - }; - - // Debug info - console.log('[Usage Component] Render state:', { - limitsLoading, - limitsCount: limits.length, - groupedLimits, - plannerCount: groupedLimits.planner.length, - writerCount: groupedLimits.writer.length, - imagesCount: groupedLimits.images.length, - aiCount: groupedLimits.ai.length, - generalCount: groupedLimits.general.length, - }); + // Filter limits to show only credits and account management (Phase 0: Credit-only system) + const creditLimits = limits.filter(l => l.category === 'credits'); + const accountLimits = limits.filter(l => l.category === 'account'); return (
- +
-

Acoount Limits Usage 12

-

Monitor your plan limits and usage statistics

+

Credit Usage & Limits

+

Monitor your credit usage and account management limits

- {/* Debug Info - Remove in production */} - {import.meta.env.DEV && ( - -
- Debug: Loading={limitsLoading ? 'Yes' : 'No'}, Limits={limits.length}, - Planner={groupedLimits.planner.length}, Writer={groupedLimits.writer.length}, - Images={groupedLimits.images.length}, AI={groupedLimits.ai.length}, General={groupedLimits.general.length} -
-
- )} + {/* Credit Costs Reference */} + +

Credit Costs per Operation

+
+ {Object.entries(CREDIT_COSTS).map(([operation, info]) => ( +
+
+
+ {operation.replace(/_/g, ' ')} +
+
+ {info.description} +
+
+
+ + {typeof info.cost === 'number' ? `${info.cost} credits` : info.cost} + +
+
+ ))} +
+
- {/* Limit Cards by Category */} + {/* Credit Limits */} {limitsLoading ? (
Loading limits...
- ) : limits.length === 0 ? ( - -
-

No usage limits data available.

-

The API endpoint may not be responding or your account may not have a plan configured.

-

Check browser console for errors. Endpoint: /v1/billing/credits/usage/limits/

-
-
) : (
- {/* Planner Limits */} - {groupedLimits.planner.length > 0 && ( + {/* Credit Usage Limits */} + {creditLimits.length > 0 && (
-

Planner Limits

+

Credit Usage

- {groupedLimits.planner.map((limit, idx) => ( + {creditLimits.map((limit, idx) => ( ))}
)} - {/* Writer Limits */} - {groupedLimits.writer.length > 0 && ( + {/* Account Management Limits */} + {accountLimits.length > 0 && (
-

Writer Limits

+

Account Management

- {groupedLimits.writer.map((limit, idx) => ( + {accountLimits.map((limit, idx) => ( ))}
)} - {/* Image Limits */} - {groupedLimits.images.length > 0 && ( -
-

Image Generation Limits

-
- {groupedLimits.images.map((limit, idx) => ( - - ))} + {creditLimits.length === 0 && accountLimits.length === 0 && ( + +
+

No limits data available.

+

Your account may not have a plan configured.

-
- )} - - {/* AI Credits */} - {groupedLimits.ai.length > 0 && ( -
-

AI Credits

-
- {groupedLimits.ai.map((limit, idx) => ( - - ))} -
-
- )} - - {/* General Limits */} - {groupedLimits.general.length > 0 && ( -
-

General Limits

-
- {groupedLimits.general.map((limit, idx) => ( - - ))} -
-
+ )}
)} @@ -219,22 +189,20 @@ export default function Usage() { function LimitCardComponent({ limit }: { limit: LimitCard }) { const getCategoryColor = (category: string) => { switch (category) { - case 'planner': return 'blue'; - case 'writer': return 'green'; - case 'images': return 'purple'; - case 'ai': return 'orange'; - case 'general': return 'gray'; + case 'credits': return 'primary'; + case 'account': return 'gray'; default: return 'gray'; } }; - const getUsageStatus = (percentage: number) => { + const getUsageStatus = (percentage: number | null) => { + if (percentage === null) return 'info'; if (percentage >= 90) return 'danger'; if (percentage >= 75) return 'warning'; return 'success'; }; - const percentage = Math.min(limit.percentage, 100); + const percentage = limit.percentage !== null && limit.percentage !== undefined ? Math.min(limit.percentage, 100) : null; const status = getUsageStatus(percentage); const color = getCategoryColor(limit.category); @@ -242,12 +210,16 @@ function LimitCardComponent({ limit }: { limit: LimitCard }) { ? 'bg-red-500' : status === 'warning' ? 'bg-yellow-500' + : status === 'info' + ? 'bg-blue-500' : 'bg-green-500'; const statusTextColor = status === 'danger' ? 'text-red-600 dark:text-red-400' : status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' + : status === 'info' + ? 'text-blue-600 dark:text-blue-400' : 'text-green-600 dark:text-green-400'; return ( @@ -258,26 +230,44 @@ function LimitCardComponent({ limit }: { limit: LimitCard }) {
- {limit.used.toLocaleString()} - / {limit.limit.toLocaleString()} - {limit.unit} + {limit.limit !== null && limit.limit !== undefined ? ( + <> + {limit.used.toLocaleString()} + / {limit.limit.toLocaleString()} + + ) : ( + + {limit.available !== null && limit.available !== undefined ? limit.available.toLocaleString() : limit.used.toLocaleString()} + + )} + {limit.unit && ( + {limit.unit} + )}
-
-
-
+ {percentage !== null && ( +
+
+
+
-
+ )}
- - {limit.available.toLocaleString()} available - - - {percentage.toFixed(1)}% used - + {limit.available !== null && limit.available !== undefined ? ( + + {limit.available.toLocaleString()} available + + ) : ( + Current value + )} + {percentage !== null && ( + + {percentage.toFixed(1)}% used + + )}
); From ab6b6cc4be8f6a49da18268ddcf732461b9b4c70 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:06:48 +0000 Subject: [PATCH 12/35] Phase 0: Add credit costs display to Credits page - Added credit costs reference table to Credits page - Shows cost per operation type with descriptions - Consistent with Usage page credit costs display - Helps users understand credit consumption --- frontend/src/pages/Billing/Credits.tsx | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/frontend/src/pages/Billing/Credits.tsx b/frontend/src/pages/Billing/Credits.tsx index 1aeea780..bb7e66b0 100644 --- a/frontend/src/pages/Billing/Credits.tsx +++ b/frontend/src/pages/Billing/Credits.tsx @@ -5,6 +5,19 @@ import { fetchCreditBalance, CreditBalance } from '../../services/api'; import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; +// Credit costs per operation (Phase 0: Credit-only system) +const CREDIT_COSTS: Record = { + clustering: { cost: 10, description: 'Per clustering request' }, + idea_generation: { cost: 15, description: 'Per cluster → ideas request' }, + content_generation: { cost: '1 per 100 words', description: 'Per 100 words generated' }, + image_prompt_extraction: { cost: 2, description: 'Per content piece' }, + image_generation: { cost: 5, description: 'Per image generated' }, + linking: { cost: 8, description: 'Per content piece' }, + optimization: { cost: '1 per 200 words', description: 'Per 200 words optimized' }, + site_structure_generation: { cost: 50, description: 'Per site blueprint' }, + site_page_generation: { cost: 20, description: 'Per page generated' }, +}; + export default function Credits() { const toast = useToast(); const [balance, setBalance] = useState(null); @@ -88,6 +101,35 @@ export default function Credits() {
)} + + {/* Credit Costs Reference */} +
+ +

Credit Costs per Operation

+

+ Understanding how credits are consumed for each operation type +

+
+ {Object.entries(CREDIT_COSTS).map(([operation, info]) => ( +
+
+
+ {operation.replace(/_/g, ' ')} +
+
+ {info.description} +
+
+
+ + {typeof info.cost === 'number' ? `${info.cost} credits` : info.cost} + +
+
+ ))} +
+
+
); } From f195b6a72a27148187c52bc939a58ad200636b9a Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:13:12 +0000 Subject: [PATCH 13/35] Phase 0: Fix infinite loop in AppSidebar and module settings loading - Fixed infinite loop by memoizing moduleEnabled with useCallback - Fixed useEffect dependencies to prevent re-render loops - Added loading check to prevent duplicate API calls - Fixed setState calls to only update when values actually change - Removed unused import (isModuleEnabled from modules.config) --- backend/celerybeat-schedule | Bin 16384 -> 16384 bytes frontend/src/layout/AppSidebar.tsx | 54 ++++++++++++++++++----------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule index eae734152666c67abfd5878003453c998ff34a1d..0ec84998255384c0207b5178feb08014951cc6e5 100644 GIT binary patch delta 402 zcmYL^y-EW?6oq%9kZ67gScxFPN`)N~D^aY1f(9(2Ha5%tT(d(avtedZtPrwQs%bRW z-oh7Z1=6>8W_uM&IMzV~2c8)4eaT;ZHx&??x+wXepCZu}Qsxxm{js71a8%_e~6A6MAl zt^U;8do{P=Tv{cVF|L`gAF7T(`&vu8+|*tZ0ZhCk7N0HN=4ksn-b_qcIOc#$9HQBJ zdhFwXEb(Ue*BCgaUFK3{pxil>HoYI@u?CKPLr(2be4wqx9D|d&y>nLdyC+(|@STXc delta 64 zcmZo@U~Fh$T%aJt$N&MG_#w2P!9>HT%{dB7c=;F%AVTawij`rroSp&G=1GQmd>b8H F7y)!63&a2b diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index c2fadeb4..4f9c2b9e 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -21,7 +21,6 @@ import SidebarWidget from "./SidebarWidget"; import { APP_VERSION } from "../config/version"; import { useAuthStore } from "../store/authStore"; import { useSettingsStore } from "../store/settingsStore"; -import { isModuleEnabled } from "../config/modules.config"; import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator"; type NavItem = { @@ -40,7 +39,7 @@ const AppSidebar: React.FC = () => { const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar(); const location = useLocation(); const { user } = useAuthStore(); - const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled } = useSettingsStore(); + const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled, loadModuleEnableSettings, loading: settingsLoading } = useSettingsStore(); // Show admin menu only for users in aws-admin account const isAwsAdminAccount = Boolean( @@ -48,11 +47,11 @@ const AppSidebar: React.FC = () => { user?.role === 'developer' // Also show for developers as fallback ); - // Helper to check if module is enabled - const moduleEnabled = (moduleName: string): boolean => { + // Helper to check if module is enabled - memoized to prevent infinite loops + const moduleEnabled = useCallback((moduleName: string): boolean => { if (!moduleEnableSettings) return true; // Default to enabled if not loaded return checkModuleEnabled(moduleName); - }; + }, [moduleEnableSettings, checkModuleEnabled]); const [openSubmenu, setOpenSubmenu] = useState<{ sectionIndex: number; @@ -68,6 +67,15 @@ const AppSidebar: React.FC = () => { [location.pathname] ); + // Load module enable settings on mount (only once) + useEffect(() => { + if (!moduleEnableSettings && !settingsLoading) { + loadModuleEnableSettings().catch((error) => { + console.warn('Failed to load module enable settings:', error); + }); + } + }, []); // Empty dependency array - only run on mount + // Define menu sections with useMemo to prevent recreation on every render // Filter out disabled modules based on module enable settings const menuSections: MenuSection[] = useMemo(() => { @@ -196,7 +204,7 @@ const AppSidebar: React.FC = () => { ], }, ]; - }, [moduleEnableSettings, moduleEnabled]); + }, [moduleEnabled]); // Admin section - only shown for users in aws-admin account const adminSection: MenuSection = useMemo(() => ({ @@ -282,14 +290,6 @@ const AppSidebar: React.FC = () => { : menuSections; }, [isAwsAdminAccount, menuSections, adminSection]); - // Load module enable settings on mount - useEffect(() => { - const { loadModuleEnableSettings } = useSettingsStore.getState(); - if (!moduleEnableSettings) { - loadModuleEnableSettings(); - } - }, [moduleEnableSettings]); - useEffect(() => { const currentPath = location.pathname; let foundMatch = false; @@ -305,9 +305,15 @@ const AppSidebar: React.FC = () => { }); if (shouldOpen) { - setOpenSubmenu({ - sectionIndex, - itemIndex, + setOpenSubmenu((prev) => { + // Only update if different to prevent infinite loops + if (prev?.sectionIndex === sectionIndex && prev?.itemIndex === itemIndex) { + return prev; + } + return { + sectionIndex, + itemIndex, + }; }); foundMatch = true; } @@ -330,10 +336,16 @@ const AppSidebar: React.FC = () => { // scrollHeight should work even when height is 0px due to overflow-hidden const scrollHeight = element.scrollHeight; if (scrollHeight > 0) { - setSubMenuHeight((prevHeights) => ({ - ...prevHeights, - [key]: scrollHeight, - })); + setSubMenuHeight((prevHeights) => { + // Only update if height changed to prevent infinite loops + if (prevHeights[key] === scrollHeight) { + return prevHeights; + } + return { + ...prevHeights, + [key]: scrollHeight, + }; + }); } } }, 50); From 4de912843078410e8c7589a72a87eacbed9a50a1 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:14:53 +0000 Subject: [PATCH 14/35] Phase 0: Fix ModuleEnableSettings permissions - allow read access to all authenticated users - Changed permission_classes to get_permissions() method - Read operations (list, retrieve) now accessible to all authenticated users - Write operations (update, partial_update) still restricted to admins/owners - Fixes 403 Forbidden errors when loading module settings in sidebar --- .../igny8_core/modules/system/settings_views.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index a423f193..d9521531 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -287,14 +287,26 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): ViewSet for managing module enable/disable settings Unified API Standard v1.0 compliant One record per account + Read access: All authenticated users + Write access: Admins/Owners only """ queryset = ModuleEnableSettings.objects.all() serializer_class = ModuleEnableSettingsSerializer - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] throttle_scope = 'system' throttle_classes = [DebugScopedRateThrottle] + def get_permissions(self): + """ + Allow read access to all authenticated users, + but restrict write access to admins/owners + """ + if self.action in ['list', 'retrieve']: + permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] + else: + permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] + return [permission() for permission in permission_classes] + def get_queryset(self): """Get module enable settings for current account""" queryset = super().get_queryset() From 437b0c7516c00b85ad816aefd3180983130f2528 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:16:07 +0000 Subject: [PATCH 15/35] Phase 0: Fix AppSidebar to only load module settings when authenticated - Added isAuthenticated check before loading module enable settings - Prevents 403 errors when user is not logged in - Only loads settings when user is authenticated and settings aren't already loaded --- frontend/src/layout/AppSidebar.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 4f9c2b9e..fe1c2825 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -38,7 +38,7 @@ type MenuSection = { const AppSidebar: React.FC = () => { const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar(); const location = useLocation(); - const { user } = useAuthStore(); + const { user, isAuthenticated } = useAuthStore(); const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled, loadModuleEnableSettings, loading: settingsLoading } = useSettingsStore(); // Show admin menu only for users in aws-admin account @@ -67,14 +67,15 @@ const AppSidebar: React.FC = () => { [location.pathname] ); - // Load module enable settings on mount (only once) + // Load module enable settings on mount (only once) - but only if user is authenticated useEffect(() => { - if (!moduleEnableSettings && !settingsLoading) { + // Only load if user is authenticated and settings aren't already loaded + if (user && isAuthenticated && !moduleEnableSettings && !settingsLoading) { loadModuleEnableSettings().catch((error) => { console.warn('Failed to load module enable settings:', error); }); } - }, []); // Empty dependency array - only run on mount + }, [user, isAuthenticated]); // Only run when user/auth state changes // Define menu sections with useMemo to prevent recreation on every render // Filter out disabled modules based on module enable settings From b20fab8ec14e72e362a9e286dde991119f8a02cb Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:16:43 +0000 Subject: [PATCH 16/35] Phase 0: Fix AppLayout to only load sites when authenticated - Added isAuthenticated check before loading active site - Prevents 403 errors when user is not logged in - Only loads sites when user is authenticated - Silently handles 403 errors (expected when not authenticated) --- frontend/src/layout/AppLayout.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/layout/AppLayout.tsx b/frontend/src/layout/AppLayout.tsx index f2b6fbbc..1483e847 100644 --- a/frontend/src/layout/AppLayout.tsx +++ b/frontend/src/layout/AppLayout.tsx @@ -24,8 +24,11 @@ const LayoutContent: React.FC = () => { const [debugEnabled, setDebugEnabled] = useState(false); const lastUserRefresh = useRef(0); - // Initialize site store on mount - only once + // Initialize site store on mount - only once, but only if authenticated useEffect(() => { + // Only load sites if user is authenticated + if (!isAuthenticated) return; + if (!hasLoadedSite.current && !isLoadingSite.current) { hasLoadedSite.current = true; isLoadingSite.current = true; @@ -44,8 +47,11 @@ const LayoutContent: React.FC = () => { loadActiveSite() .catch((error) => { - console.error('AppLayout: Error loading active site:', error); - addError(error, 'AppLayout.loadActiveSite'); + // Don't log 403 errors as they're expected when not authenticated + if (error.status !== 403) { + console.error('AppLayout: Error loading active site:', error); + addError(error, 'AppLayout.loadActiveSite'); + } }) .finally(() => { clearTimeout(timeoutId); @@ -53,7 +59,7 @@ const LayoutContent: React.FC = () => { isLoadingSite.current = false; }); } - }, []); // Empty deps - only run once on mount + }, [isAuthenticated]); // Run when authentication state changes // Load sectors when active site changes (by ID, not object reference) useEffect(() => { From 1d39f3f00a03c87ee46c9c0362218ae9b3063cba Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:22:45 +0000 Subject: [PATCH 17/35] Phase 0: Fix token race condition causing logout after login - Updated getAuthToken/getRefreshToken to read from Zustand store first (faster, no parsing delay) - Added token existence check before making API calls in AppLayout - Added retry mechanism with 100ms delay to wait for Zustand persist to write token - Made 403 error handler smarter - only logout if token actually exists (prevents false logouts) - Fixes issue where user gets logged out immediately after successful login --- frontend/src/layout/AppLayout.tsx | 94 +++++++++++++++++++++---------- frontend/src/services/api.ts | 29 ++++++++-- 2 files changed, 88 insertions(+), 35 deletions(-) diff --git a/frontend/src/layout/AppLayout.tsx b/frontend/src/layout/AppLayout.tsx index 1483e847..6d965581 100644 --- a/frontend/src/layout/AppLayout.tsx +++ b/frontend/src/layout/AppLayout.tsx @@ -26,39 +26,60 @@ const LayoutContent: React.FC = () => { // Initialize site store on mount - only once, but only if authenticated useEffect(() => { - // Only load sites if user is authenticated + // Only load sites if user is authenticated AND has a token if (!isAuthenticated) return; - if (!hasLoadedSite.current && !isLoadingSite.current) { - hasLoadedSite.current = true; - isLoadingSite.current = true; - trackLoading('site-loading', true); - - // Add timeout to prevent infinite loading - // Match API timeout (30s) + buffer for network delays - const timeoutId = setTimeout(() => { - if (isLoadingSite.current) { - console.error('AppLayout: Site loading timeout after 35 seconds'); - trackLoading('site-loading', false); - isLoadingSite.current = false; - addError(new Error('Site loading timeout - check network connection'), 'AppLayout.loadActiveSite'); - } - }, 35000); // 35 seconds to match API timeout (30s) + buffer - - loadActiveSite() - .catch((error) => { - // Don't log 403 errors as they're expected when not authenticated - if (error.status !== 403) { - console.error('AppLayout: Error loading active site:', error); - addError(error, 'AppLayout.loadActiveSite'); + // Check if token exists - if not, wait a bit for Zustand persist to write it + const checkTokenAndLoad = () => { + const authState = useAuthStore.getState(); + if (!authState?.token) { + // Token not available yet - wait a bit and retry (Zustand persist might still be writing) + setTimeout(() => { + const retryAuthState = useAuthStore.getState(); + if (retryAuthState?.token && !hasLoadedSite.current && !isLoadingSite.current) { + loadSites(); } - }) - .finally(() => { - clearTimeout(timeoutId); - trackLoading('site-loading', false); - isLoadingSite.current = false; - }); - } + }, 100); // Wait 100ms for persist to write + return; + } + + loadSites(); + }; + + const loadSites = () => { + if (!hasLoadedSite.current && !isLoadingSite.current) { + hasLoadedSite.current = true; + isLoadingSite.current = true; + trackLoading('site-loading', true); + + // Add timeout to prevent infinite loading + // Match API timeout (30s) + buffer for network delays + const timeoutId = setTimeout(() => { + if (isLoadingSite.current) { + console.error('AppLayout: Site loading timeout after 35 seconds'); + trackLoading('site-loading', false); + isLoadingSite.current = false; + addError(new Error('Site loading timeout - check network connection'), 'AppLayout.loadActiveSite'); + } + }, 35000); // 35 seconds to match API timeout (30s) + buffer + + loadActiveSite() + .catch((error) => { + // Don't log 403 errors as they're expected when not authenticated + if (error.status !== 403) { + console.error('AppLayout: Error loading active site:', error); + addError(error, 'AppLayout.loadActiveSite'); + } + }) + .finally(() => { + clearTimeout(timeoutId); + trackLoading('site-loading', false); + isLoadingSite.current = false; + }); + } + }; + + checkTokenAndLoad(); }, [isAuthenticated]); // Run when authentication state changes // Load sectors when active site changes (by ID, not object reference) @@ -120,6 +141,19 @@ const LayoutContent: React.FC = () => { // Throttle: only refresh if last refresh was more than 30 seconds ago (unless forced) if (!force && now - lastUserRefresh.current < 30000) return; + // Check if token exists before making API call + const authState = useAuthStore.getState(); + if (!authState?.token) { + // Token not available yet - wait a bit for Zustand persist to write it + setTimeout(() => { + const retryAuthState = useAuthStore.getState(); + if (retryAuthState?.token && retryAuthState?.isAuthenticated) { + refreshUserData(force); + } + }, 100); // Wait 100ms for persist to write + return; + } + try { lastUserRefresh.current = now; await refreshUser(); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 566bf003..4ec8d50e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -78,9 +78,16 @@ function getActiveSectorId(): number | null { } } -// Get auth token from store +// Get auth token from store - try Zustand store first, then localStorage as fallback const getAuthToken = (): string | null => { try { + // First try to get from Zustand store directly (faster, no parsing) + const authState = useAuthStore.getState(); + if (authState?.token) { + return authState.token; + } + + // Fallback to localStorage (for cases where store hasn't initialized yet) const authStorage = localStorage.getItem('auth-storage'); if (authStorage) { const parsed = JSON.parse(authStorage); @@ -92,9 +99,16 @@ const getAuthToken = (): string | null => { return null; }; -// Get refresh token from store +// Get refresh token from store - try Zustand store first, then localStorage as fallback const getRefreshToken = (): string | null => { try { + // First try to get from Zustand store directly (faster, no parsing) + const authState = useAuthStore.getState(); + if (authState?.refreshToken) { + return authState.refreshToken; + } + + // Fallback to localStorage (for cases where store hasn't initialized yet) const authStorage = localStorage.getItem('auth-storage'); if (authStorage) { const parsed = JSON.parse(authStorage); @@ -148,9 +162,14 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo if (errorData?.detail?.includes('Authentication credentials') || errorData?.message?.includes('Authentication credentials') || errorData?.error?.includes('Authentication credentials')) { - // Token is invalid - clear auth state and force re-login - const { logout } = useAuthStore.getState(); - logout(); + // Only logout if we actually have a token stored (means it's invalid) + // If no token, it might be a race condition after login - don't logout + const authState = useAuthStore.getState(); + if (authState?.token || authState?.isAuthenticated) { + // Token exists but is invalid - clear auth state and force re-login + const { logout } = useAuthStore.getState(); + logout(); + } // Don't throw here - let the error handling below show the error } } catch (e) { From 8fc483251e62f981c19c62ddcef2a4354eec56f7 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:25:05 +0000 Subject: [PATCH 18/35] Phase 0: Fix ModuleEnableSettings 404 error - improve error handling - Added proper exception handling in list() and retrieve() methods - Use objects.get_or_create() directly instead of class method - Added *args, **kwargs to method signatures for DRF compatibility - Better error messages for debugging - Fixes 404 'Setting not found' errors --- .../modules/system/settings_views.py | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index d9521531..1306245d 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -312,45 +312,59 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): queryset = super().get_queryset() return queryset - def list(self, request): + def list(self, request, *args, **kwargs): """Get or create module enable settings for current account""" - account = getattr(request, 'account', None) - if not account: - user = getattr(request, 'user', None) - if user: - account = getattr(user, 'account', None) - - if not account: + try: + account = getattr(request, 'account', None) + if not account: + user = getattr(request, 'user', None) + if user: + account = getattr(user, 'account', None) + + if not account: + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get or create settings for account + settings, created = ModuleEnableSettings.objects.get_or_create(account=account) + serializer = self.get_serializer(settings) + return success_response(data=serializer.data, request=request) + except Exception as e: return error_response( - error='Account not found', - status_code=status.HTTP_400_BAD_REQUEST, + error=f'Failed to load module enable settings: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) - - # Get or create settings for account - settings = ModuleEnableSettings.get_or_create_for_account(account) - serializer = self.get_serializer(settings) - return success_response(data=serializer.data, request=request) - def retrieve(self, request, pk=None): + def retrieve(self, request, pk=None, *args, **kwargs): """Get module enable settings for current account""" - account = getattr(request, 'account', None) - if not account: - user = getattr(request, 'user', None) - if user: - account = getattr(user, 'account', None) - - if not account: + try: + account = getattr(request, 'account', None) + if not account: + user = getattr(request, 'user', None) + if user: + account = getattr(user, 'account', None) + + if not account: + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get or create settings for account + settings, created = ModuleEnableSettings.objects.get_or_create(account=account) + serializer = self.get_serializer(settings) + return success_response(data=serializer.data, request=request) + except Exception as e: return error_response( - error='Account not found', - status_code=status.HTTP_400_BAD_REQUEST, + error=f'Failed to load module enable settings: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) - - # Get or create settings for account - settings = ModuleEnableSettings.get_or_create_for_account(account) - serializer = self.get_serializer(settings) - return success_response(data=serializer.data, request=request) def update(self, request, pk=None): """Update module enable settings for current account""" From 0d468ef15a7b767bdd5645f722acfefe098ff543 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:25:36 +0000 Subject: [PATCH 19/35] Phase 0: Improve ModuleEnableSettings get_queryset to filter by account - Updated get_queryset to properly filter by account - Ensures queryset is account-scoped before list() is called - Prevents potential conflicts with base class behavior --- backend/igny8_core/modules/system/settings_views.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 1306245d..fac91a30 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -309,7 +309,16 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): def get_queryset(self): """Get module enable settings for current account""" + # Return queryset filtered by account - but list() will handle get_or_create queryset = super().get_queryset() + # Filter by account if available + account = getattr(self.request, 'account', None) + if not account: + user = getattr(self.request, 'user', None) + if user: + account = getattr(user, 'account', None) + if account: + queryset = queryset.filter(account=account) return queryset def list(self, request, *args, **kwargs): From 9ec89080917525c71290b19b7698fc78dd319846 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:26:18 +0000 Subject: [PATCH 20/35] Phase 0: Fix ModuleEnableSettings list() - use get() instead of get_or_create - Changed to use get() with DoesNotExist exception handling - Creates settings only if they don't exist - Better error handling with traceback - Fixes 404 'Setting not found' errors --- .../igny8_core/modules/system/settings_views.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index fac91a30..51422645 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -327,8 +327,8 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): account = getattr(request, 'account', None) if not account: user = getattr(request, 'user', None) - if user: - account = getattr(user, 'account', None) + if user and hasattr(user, 'account'): + account = user.account if not account: return error_response( @@ -337,11 +337,18 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): request=request ) - # Get or create settings for account - settings, created = ModuleEnableSettings.objects.get_or_create(account=account) + # Get or create settings for account (one per account) + try: + settings = ModuleEnableSettings.objects.get(account=account) + except ModuleEnableSettings.DoesNotExist: + # Create default settings for account + settings = ModuleEnableSettings.objects.create(account=account) + serializer = self.get_serializer(settings) return success_response(data=serializer.data, request=request) except Exception as e: + import traceback + error_trace = traceback.format_exc() return error_response( error=f'Failed to load module enable settings: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, From a267fc071522525ccc79c53992a87d367c998a53 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:28:37 +0000 Subject: [PATCH 21/35] Fix authentication: Ensure correct user/account is loaded - JWTAuthentication now uses select_related('account', 'account__plan') to get fresh user data - Added check to use user's current account if it differs from token's account_id - This ensures correct user/account is shown even if account changed after token was issued - Fixes bug where wrong user was displayed after login --- backend/igny8_core/api/authentication.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/igny8_core/api/authentication.py b/backend/igny8_core/api/authentication.py index f20ec8a5..e3930a88 100644 --- a/backend/igny8_core/api/authentication.py +++ b/backend/igny8_core/api/authentication.py @@ -55,7 +55,9 @@ class JWTAuthentication(BaseAuthentication): return None try: - user = User.objects.get(id=user_id) + # Refresh user from DB with account and plan relationships to get latest data + # This ensures changes to account/plan are reflected immediately without re-login + user = User.objects.select_related('account', 'account__plan').get(id=user_id) except User.DoesNotExist: # User not found - return None to allow other auth classes to try return None @@ -66,7 +68,12 @@ class JWTAuthentication(BaseAuthentication): if account_id: try: account = Account.objects.get(id=account_id) + # If user's account changed, use the new one from user object (most up-to-date) + # This ensures we always use the user's current account, not a stale token account_id + if user.account and user.account.id != account_id: + account = user.account except Account.DoesNotExist: + # Account from token doesn't exist - use user's account instead pass if not account: From 46b5b5f1b2f9ba4d4f1dfffae2045758ac649339 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:34:02 +0000 Subject: [PATCH 22/35] Fix authentication: Use token's account_id as authoritative source - Token's account_id is now authoritative for current account context - For developers/admins: Always use token's account_id (they can access any account) - For regular users: Verify they belong to token's account, fallback to user.account if not - This ensures correct account context is set, especially for developers working across accounts - Fixes bug where wrong user/account was shown after login --- backend/igny8_core/api/authentication.py | 14 +++++++++----- backend/igny8_core/auth/middleware.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/igny8_core/api/authentication.py b/backend/igny8_core/api/authentication.py index e3930a88..723abeef 100644 --- a/backend/igny8_core/api/authentication.py +++ b/backend/igny8_core/api/authentication.py @@ -62,16 +62,20 @@ class JWTAuthentication(BaseAuthentication): # User not found - return None to allow other auth classes to try return None - # Get account from token + # Get account from token (token's account_id is authoritative for current context) account_id = payload.get('account_id') account = None if account_id: try: account = Account.objects.get(id=account_id) - # If user's account changed, use the new one from user object (most up-to-date) - # This ensures we always use the user's current account, not a stale token account_id - if user.account and user.account.id != account_id: - account = user.account + # Verify user has access to this account + # For developers/admins, they can access any account + # For regular users, verify they belong to this account + if not user.is_admin_or_developer() and not user.is_system_account_user(): + # Regular user - must belong to this account + if user.account and user.account.id != account_id: + # User doesn't belong to token's account - use user's account instead + account = user.account except Account.DoesNotExist: # Account from token doesn't exist - use user's account instead pass diff --git a/backend/igny8_core/auth/middleware.py b/backend/igny8_core/auth/middleware.py index 1bcdd16f..5b82fb26 100644 --- a/backend/igny8_core/auth/middleware.py +++ b/backend/igny8_core/auth/middleware.py @@ -99,12 +99,20 @@ class AccountContextMiddleware(MiddlewareMixin): user = User.objects.select_related('account', 'account__plan').get(id=user_id) request.user = user if account_id: - # Verify account still exists and matches user + # Verify account still exists account = Account.objects.get(id=account_id) - # If user's account changed, use the new one from user object - if user.account and user.account.id != account_id: - request.account = user.account + # Token's account_id is authoritative for current context + # For developers/admins, they can access any account + # For regular users, verify they belong to this account + if not user.is_admin_or_developer() and not user.is_system_account_user(): + # Regular user - must belong to this account + if user.account and user.account.id != account_id: + # User doesn't belong to token's account - use user's account instead + request.account = user.account + else: + request.account = account else: + # Developer/admin/system user - use token's account (they can access any) request.account = account else: try: From 8171014a7eb1864673bd81d89e81a9f82e6a77be Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:36:18 +0000 Subject: [PATCH 23/35] Fix authentication: Follow unified API model - token account_id is authoritative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplified authentication logic to match unified API documentation - Token's account_id is now the sole source of truth for account context - Removed validation against user.account (no longer valid per unified API model) - Middleware now simply extracts account_id from JWT and sets request.account - Matches documented flow: Extract Account ID → Load Account Object → Set request.account --- backend/igny8_core/api/authentication.py | 22 ++++------------------ backend/igny8_core/auth/middleware.py | 21 +++++++-------------- 2 files changed, 11 insertions(+), 32 deletions(-) diff --git a/backend/igny8_core/api/authentication.py b/backend/igny8_core/api/authentication.py index 723abeef..9daada1b 100644 --- a/backend/igny8_core/api/authentication.py +++ b/backend/igny8_core/api/authentication.py @@ -62,32 +62,18 @@ class JWTAuthentication(BaseAuthentication): # User not found - return None to allow other auth classes to try return None - # Get account from token (token's account_id is authoritative for current context) + # Get account from token (token's account_id is authoritative per unified API model) + # Unified API Standard: Token contains account_id, middleware extracts and sets request.account account_id = payload.get('account_id') account = None if account_id: try: account = Account.objects.get(id=account_id) - # Verify user has access to this account - # For developers/admins, they can access any account - # For regular users, verify they belong to this account - if not user.is_admin_or_developer() and not user.is_system_account_user(): - # Regular user - must belong to this account - if user.account and user.account.id != account_id: - # User doesn't belong to token's account - use user's account instead - account = user.account except Account.DoesNotExist: - # Account from token doesn't exist - use user's account instead - pass - - if not account: - try: - account = getattr(user, 'account', None) - except (AttributeError, Exception): - # If account access fails, set to None + # Account from token doesn't exist - set to None account = None - # Set account on request + # Set account on request (unified API model: token's account_id is authoritative) request.account = account return (user, token) diff --git a/backend/igny8_core/auth/middleware.py b/backend/igny8_core/auth/middleware.py index 5b82fb26..f738a4b8 100644 --- a/backend/igny8_core/auth/middleware.py +++ b/backend/igny8_core/auth/middleware.py @@ -99,21 +99,14 @@ class AccountContextMiddleware(MiddlewareMixin): user = User.objects.select_related('account', 'account__plan').get(id=user_id) request.user = user if account_id: - # Verify account still exists - account = Account.objects.get(id=account_id) - # Token's account_id is authoritative for current context - # For developers/admins, they can access any account - # For regular users, verify they belong to this account - if not user.is_admin_or_developer() and not user.is_system_account_user(): - # Regular user - must belong to this account - if user.account and user.account.id != account_id: - # User doesn't belong to token's account - use user's account instead - request.account = user.account - else: - request.account = account - else: - # Developer/admin/system user - use token's account (they can access any) + # Unified API Standard: Extract account_id from JWT, load Account object, set request.account + # Token's account_id is authoritative - no validation against user.account + try: + account = Account.objects.get(id=account_id) request.account = account + except Account.DoesNotExist: + # Account from token doesn't exist - set to None + request.account = None else: try: user_account = getattr(user, 'account', None) From 066b81dd2aeb6ca2ec3b466add5e71e206e4187f Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:40:44 +0000 Subject: [PATCH 24/35] Fix credit system: Add developer/system account bypass for credit checks - CreditService.check_credits() now bypasses for: 1. System accounts (aws-admin, default-account, default) 2. Developer/admin users (if user provided) 3. Accounts with developer users (fallback for Celery tasks) - Updated check_credits_legacy() with same bypass logic - AIEngine credit check now uses updated CreditService - Fixes 52 console errors caused by credit checks blocking developers - Developers can now use AI functions without credit restrictions --- backend/igny8_core/ai/engine.py | 6 ++- .../igny8_core/modules/billing/services.py | 52 ++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/backend/igny8_core/ai/engine.py b/backend/igny8_core/ai/engine.py index 39542098..32e3a991 100644 --- a/backend/igny8_core/ai/engine.py +++ b/backend/igny8_core/ai/engine.py @@ -193,6 +193,7 @@ class AIEngine: self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta()) # Phase 2.5: CREDIT CHECK - Check credits before AI call (25%) + # Bypass for system accounts and developers (handled in CreditService) if self.account: try: from igny8_core.modules.billing.services import CreditService @@ -204,8 +205,9 @@ class AIEngine: # Calculate estimated cost estimated_amount = self._get_estimated_amount(function_name, data, payload) - # Check credits BEFORE AI call - CreditService.check_credits(self.account, operation_type, estimated_amount) + # Check credits BEFORE AI call (CreditService handles developer/system account bypass) + # Note: user=None for Celery tasks, but CreditService checks account.is_system_account() and developer users + CreditService.check_credits(self.account, operation_type, estimated_amount, user=None) logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}") except InsufficientCreditsError as e: diff --git a/backend/igny8_core/modules/billing/services.py b/backend/igny8_core/modules/billing/services.py index 64a8024e..ed823813 100644 --- a/backend/igny8_core/modules/billing/services.py +++ b/backend/igny8_core/modules/billing/services.py @@ -49,7 +49,7 @@ class CreditService: return base_cost @staticmethod - def check_credits(account, operation_type, amount=None): + def check_credits(account, operation_type, amount=None, user=None): """ Check if account has sufficient credits for an operation. @@ -57,10 +57,35 @@ class CreditService: account: Account instance operation_type: Type of operation amount: Optional amount (word count, image count, etc.) + user: Optional user instance (for developer/admin bypass) Raises: InsufficientCreditsError: If account doesn't have enough credits """ + # Bypass credit check for: + # 1. System accounts (aws-admin, default-account, default) + # 2. Developer/admin users (if user provided) + if account and account.is_system_account(): + return True + + if user: + try: + if hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer(): + return True + if hasattr(user, 'is_system_account_user') and user.is_system_account_user(): + return True + except (AttributeError, Exception): + pass + + # Check if account has any developer users (fallback for Celery tasks without user context) + if account: + try: + from igny8_core.auth.models import User + if User.objects.filter(account=account, role='developer').exists(): + return True + except (AttributeError, Exception): + pass + required = CreditService.get_credit_cost(operation_type, amount) if account.credits < required: raise InsufficientCreditsError( @@ -69,17 +94,40 @@ class CreditService: return True @staticmethod - def check_credits_legacy(account, required_credits): + def check_credits_legacy(account, required_credits, user=None): """ Legacy method: Check if account has enough credits (for backward compatibility). Args: account: Account instance required_credits: Number of credits required + user: Optional user instance (for developer/admin bypass) Raises: InsufficientCreditsError: If account doesn't have enough credits """ + # Bypass credit check for system accounts and developers + if account and account.is_system_account(): + return + + if user: + try: + if hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer(): + return + if hasattr(user, 'is_system_account_user') and user.is_system_account_user(): + return + except (AttributeError, Exception): + pass + + # Check if account has any developer users (fallback for Celery tasks) + if account: + try: + from igny8_core.auth.models import User + if User.objects.filter(account=account, role='developer').exists(): + return + except (AttributeError, Exception): + pass + if account.credits < required_credits: raise InsufficientCreditsError( f"Insufficient credits. Required: {required_credits}, Available: {account.credits}" From 219dae83c6aee5fc3682b36314d52d694fa10e5d Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 19:44:18 +0000 Subject: [PATCH 25/35] Revert to main branch account handling logic - Restored fallback to user.account when token account_id is missing/invalid - Restored validation that user.account matches token account_id - If user's account changed, use user.account (the correct one) - Matches main branch behavior which has correct config - Fixes wrong user/account showing issue --- backend/igny8_core/api/authentication.py | 17 ++++++++++------- backend/igny8_core/auth/middleware.py | 13 ++++++------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/backend/igny8_core/api/authentication.py b/backend/igny8_core/api/authentication.py index 9daada1b..f20ec8a5 100644 --- a/backend/igny8_core/api/authentication.py +++ b/backend/igny8_core/api/authentication.py @@ -55,25 +55,28 @@ class JWTAuthentication(BaseAuthentication): return None try: - # Refresh user from DB with account and plan relationships to get latest data - # This ensures changes to account/plan are reflected immediately without re-login - user = User.objects.select_related('account', 'account__plan').get(id=user_id) + user = User.objects.get(id=user_id) except User.DoesNotExist: # User not found - return None to allow other auth classes to try return None - # Get account from token (token's account_id is authoritative per unified API model) - # Unified API Standard: Token contains account_id, middleware extracts and sets request.account + # Get account from token account_id = payload.get('account_id') account = None if account_id: try: account = Account.objects.get(id=account_id) except Account.DoesNotExist: - # Account from token doesn't exist - set to None + pass + + if not account: + try: + account = getattr(user, 'account', None) + except (AttributeError, Exception): + # If account access fails, set to None account = None - # Set account on request (unified API model: token's account_id is authoritative) + # Set account on request request.account = account return (user, token) diff --git a/backend/igny8_core/auth/middleware.py b/backend/igny8_core/auth/middleware.py index f738a4b8..1bcdd16f 100644 --- a/backend/igny8_core/auth/middleware.py +++ b/backend/igny8_core/auth/middleware.py @@ -99,14 +99,13 @@ class AccountContextMiddleware(MiddlewareMixin): user = User.objects.select_related('account', 'account__plan').get(id=user_id) request.user = user if account_id: - # Unified API Standard: Extract account_id from JWT, load Account object, set request.account - # Token's account_id is authoritative - no validation against user.account - try: - account = Account.objects.get(id=account_id) + # Verify account still exists and matches user + account = Account.objects.get(id=account_id) + # If user's account changed, use the new one from user object + if user.account and user.account.id != account_id: + request.account = user.account + else: request.account = account - except Account.DoesNotExist: - # Account from token doesn't exist - set to None - request.account = None else: try: user_account = getattr(user, 'account', None) From 8a9dd44c5030c2a6a10a9515daa6704958bce8c7 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 20:08:58 +0000 Subject: [PATCH 26/35] branch 1st --- .../modules/system/settings_views.py | 56 ++++++------------- 1 file changed, 17 insertions(+), 39 deletions(-) diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 51422645..5277784b 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -309,51 +309,29 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): def get_queryset(self): """Get module enable settings for current account""" - # Return queryset filtered by account - but list() will handle get_or_create - queryset = super().get_queryset() - # Filter by account if available - account = getattr(self.request, 'account', None) - if not account: - user = getattr(self.request, 'user', None) - if user: - account = getattr(user, 'account', None) - if account: - queryset = queryset.filter(account=account) - return queryset + # Don't filter here - list() and retrieve() handle get_or_create + # This prevents empty queryset from causing 404 errors + return ModuleEnableSettings.objects.all() def list(self, request, *args, **kwargs): """Get or create module enable settings for current account""" - try: - account = getattr(request, 'account', None) - if not account: - user = getattr(request, 'user', None) - if user and hasattr(user, 'account'): - account = user.account - - if not account: - return error_response( - error='Account not found', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get or create settings for account (one per account) - try: - settings = ModuleEnableSettings.objects.get(account=account) - except ModuleEnableSettings.DoesNotExist: - # Create default settings for account - settings = ModuleEnableSettings.objects.create(account=account) - - serializer = self.get_serializer(settings) - return success_response(data=serializer.data, request=request) - except Exception as e: - import traceback - error_trace = traceback.format_exc() + account = getattr(request, 'account', None) + if not account: + user = getattr(request, 'user', None) + if user and hasattr(user, 'account'): + account = getattr(user, 'account', None) + + if not account: return error_response( - error=f'Failed to load module enable settings: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, request=request ) + + # Get or create settings for account (one per account) + settings, created = ModuleEnableSettings.objects.get_or_create(account=account) + serializer = self.get_serializer(settings) + return success_response(data=serializer.data, request=request) def retrieve(self, request, pk=None, *args, **kwargs): """Get module enable settings for current account""" From ab292de06c5365cbb1a10143afeb4e01d69bf15c Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 17 Nov 2025 01:35:13 +0500 Subject: [PATCH 27/35] Revert "branch 1st" This reverts commit 8a9dd44c5030c2a6a10a9515daa6704958bce8c7. --- .../modules/system/settings_views.py | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 5277784b..51422645 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -309,29 +309,51 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): def get_queryset(self): """Get module enable settings for current account""" - # Don't filter here - list() and retrieve() handle get_or_create - # This prevents empty queryset from causing 404 errors - return ModuleEnableSettings.objects.all() + # Return queryset filtered by account - but list() will handle get_or_create + queryset = super().get_queryset() + # Filter by account if available + account = getattr(self.request, 'account', None) + if not account: + user = getattr(self.request, 'user', None) + if user: + account = getattr(user, 'account', None) + if account: + queryset = queryset.filter(account=account) + return queryset def list(self, request, *args, **kwargs): """Get or create module enable settings for current account""" - account = getattr(request, 'account', None) - if not account: - user = getattr(request, 'user', None) - if user and hasattr(user, 'account'): - account = getattr(user, 'account', None) - - if not account: + try: + account = getattr(request, 'account', None) + if not account: + user = getattr(request, 'user', None) + if user and hasattr(user, 'account'): + account = user.account + + if not account: + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get or create settings for account (one per account) + try: + settings = ModuleEnableSettings.objects.get(account=account) + except ModuleEnableSettings.DoesNotExist: + # Create default settings for account + settings = ModuleEnableSettings.objects.create(account=account) + + serializer = self.get_serializer(settings) + return success_response(data=serializer.data, request=request) + except Exception as e: + import traceback + error_trace = traceback.format_exc() return error_response( - error='Account not found', - status_code=status.HTTP_400_BAD_REQUEST, + error=f'Failed to load module enable settings: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) - - # Get or create settings for account (one per account) - settings, created = ModuleEnableSettings.objects.get_or_create(account=account) - serializer = self.get_serializer(settings) - return success_response(data=serializer.data, request=request) def retrieve(self, request, pk=None, *args, **kwargs): """Get module enable settings for current account""" From 11a5a66c8b134e2174c2f1a1fc94556b6d513ce0 Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 17 Nov 2025 01:35:19 +0500 Subject: [PATCH 28/35] Revert "Revert to main branch account handling logic" This reverts commit 219dae83c6aee5fc3682b36314d52d694fa10e5d. --- backend/igny8_core/api/authentication.py | 17 +++++++---------- backend/igny8_core/auth/middleware.py | 13 +++++++------ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/backend/igny8_core/api/authentication.py b/backend/igny8_core/api/authentication.py index f20ec8a5..9daada1b 100644 --- a/backend/igny8_core/api/authentication.py +++ b/backend/igny8_core/api/authentication.py @@ -55,28 +55,25 @@ class JWTAuthentication(BaseAuthentication): return None try: - user = User.objects.get(id=user_id) + # Refresh user from DB with account and plan relationships to get latest data + # This ensures changes to account/plan are reflected immediately without re-login + user = User.objects.select_related('account', 'account__plan').get(id=user_id) except User.DoesNotExist: # User not found - return None to allow other auth classes to try return None - # Get account from token + # Get account from token (token's account_id is authoritative per unified API model) + # Unified API Standard: Token contains account_id, middleware extracts and sets request.account account_id = payload.get('account_id') account = None if account_id: try: account = Account.objects.get(id=account_id) except Account.DoesNotExist: - pass - - if not account: - try: - account = getattr(user, 'account', None) - except (AttributeError, Exception): - # If account access fails, set to None + # Account from token doesn't exist - set to None account = None - # Set account on request + # Set account on request (unified API model: token's account_id is authoritative) request.account = account return (user, token) diff --git a/backend/igny8_core/auth/middleware.py b/backend/igny8_core/auth/middleware.py index 1bcdd16f..f738a4b8 100644 --- a/backend/igny8_core/auth/middleware.py +++ b/backend/igny8_core/auth/middleware.py @@ -99,13 +99,14 @@ class AccountContextMiddleware(MiddlewareMixin): user = User.objects.select_related('account', 'account__plan').get(id=user_id) request.user = user if account_id: - # Verify account still exists and matches user - account = Account.objects.get(id=account_id) - # If user's account changed, use the new one from user object - if user.account and user.account.id != account_id: - request.account = user.account - else: + # Unified API Standard: Extract account_id from JWT, load Account object, set request.account + # Token's account_id is authoritative - no validation against user.account + try: + account = Account.objects.get(id=account_id) request.account = account + except Account.DoesNotExist: + # Account from token doesn't exist - set to None + request.account = None else: try: user_account = getattr(user, 'account', None) From 79aab68acdf8e95dc4cb5a049d5f8fd019d01750 Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 17 Nov 2025 01:35:23 +0500 Subject: [PATCH 29/35] Revert "Fix credit system: Add developer/system account bypass for credit checks" This reverts commit 066b81dd2aeb6ca2ec3b466add5e71e206e4187f. --- backend/igny8_core/ai/engine.py | 6 +-- .../igny8_core/modules/billing/services.py | 52 +------------------ 2 files changed, 4 insertions(+), 54 deletions(-) diff --git a/backend/igny8_core/ai/engine.py b/backend/igny8_core/ai/engine.py index 32e3a991..39542098 100644 --- a/backend/igny8_core/ai/engine.py +++ b/backend/igny8_core/ai/engine.py @@ -193,7 +193,6 @@ class AIEngine: self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta()) # Phase 2.5: CREDIT CHECK - Check credits before AI call (25%) - # Bypass for system accounts and developers (handled in CreditService) if self.account: try: from igny8_core.modules.billing.services import CreditService @@ -205,9 +204,8 @@ class AIEngine: # Calculate estimated cost estimated_amount = self._get_estimated_amount(function_name, data, payload) - # Check credits BEFORE AI call (CreditService handles developer/system account bypass) - # Note: user=None for Celery tasks, but CreditService checks account.is_system_account() and developer users - CreditService.check_credits(self.account, operation_type, estimated_amount, user=None) + # Check credits BEFORE AI call + CreditService.check_credits(self.account, operation_type, estimated_amount) logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}") except InsufficientCreditsError as e: diff --git a/backend/igny8_core/modules/billing/services.py b/backend/igny8_core/modules/billing/services.py index ed823813..64a8024e 100644 --- a/backend/igny8_core/modules/billing/services.py +++ b/backend/igny8_core/modules/billing/services.py @@ -49,7 +49,7 @@ class CreditService: return base_cost @staticmethod - def check_credits(account, operation_type, amount=None, user=None): + def check_credits(account, operation_type, amount=None): """ Check if account has sufficient credits for an operation. @@ -57,35 +57,10 @@ class CreditService: account: Account instance operation_type: Type of operation amount: Optional amount (word count, image count, etc.) - user: Optional user instance (for developer/admin bypass) Raises: InsufficientCreditsError: If account doesn't have enough credits """ - # Bypass credit check for: - # 1. System accounts (aws-admin, default-account, default) - # 2. Developer/admin users (if user provided) - if account and account.is_system_account(): - return True - - if user: - try: - if hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer(): - return True - if hasattr(user, 'is_system_account_user') and user.is_system_account_user(): - return True - except (AttributeError, Exception): - pass - - # Check if account has any developer users (fallback for Celery tasks without user context) - if account: - try: - from igny8_core.auth.models import User - if User.objects.filter(account=account, role='developer').exists(): - return True - except (AttributeError, Exception): - pass - required = CreditService.get_credit_cost(operation_type, amount) if account.credits < required: raise InsufficientCreditsError( @@ -94,40 +69,17 @@ class CreditService: return True @staticmethod - def check_credits_legacy(account, required_credits, user=None): + def check_credits_legacy(account, required_credits): """ Legacy method: Check if account has enough credits (for backward compatibility). Args: account: Account instance required_credits: Number of credits required - user: Optional user instance (for developer/admin bypass) Raises: InsufficientCreditsError: If account doesn't have enough credits """ - # Bypass credit check for system accounts and developers - if account and account.is_system_account(): - return - - if user: - try: - if hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer(): - return - if hasattr(user, 'is_system_account_user') and user.is_system_account_user(): - return - except (AttributeError, Exception): - pass - - # Check if account has any developer users (fallback for Celery tasks) - if account: - try: - from igny8_core.auth.models import User - if User.objects.filter(account=account, role='developer').exists(): - return - except (AttributeError, Exception): - pass - if account.credits < required_credits: raise InsufficientCreditsError( f"Insufficient credits. Required: {required_credits}, Available: {account.credits}" From c4daeb18709647246a0e39a473ca447ba5ab2dc9 Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 17 Nov 2025 01:35:26 +0500 Subject: [PATCH 30/35] Revert "Fix authentication: Follow unified API model - token account_id is authoritative" This reverts commit 8171014a7eb1864673bd81d89e81a9f82e6a77be. --- backend/igny8_core/api/authentication.py | 22 ++++++++++++++++++---- backend/igny8_core/auth/middleware.py | 21 ++++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/backend/igny8_core/api/authentication.py b/backend/igny8_core/api/authentication.py index 9daada1b..723abeef 100644 --- a/backend/igny8_core/api/authentication.py +++ b/backend/igny8_core/api/authentication.py @@ -62,18 +62,32 @@ class JWTAuthentication(BaseAuthentication): # User not found - return None to allow other auth classes to try return None - # Get account from token (token's account_id is authoritative per unified API model) - # Unified API Standard: Token contains account_id, middleware extracts and sets request.account + # Get account from token (token's account_id is authoritative for current context) account_id = payload.get('account_id') account = None if account_id: try: account = Account.objects.get(id=account_id) + # Verify user has access to this account + # For developers/admins, they can access any account + # For regular users, verify they belong to this account + if not user.is_admin_or_developer() and not user.is_system_account_user(): + # Regular user - must belong to this account + if user.account and user.account.id != account_id: + # User doesn't belong to token's account - use user's account instead + account = user.account except Account.DoesNotExist: - # Account from token doesn't exist - set to None + # Account from token doesn't exist - use user's account instead + pass + + if not account: + try: + account = getattr(user, 'account', None) + except (AttributeError, Exception): + # If account access fails, set to None account = None - # Set account on request (unified API model: token's account_id is authoritative) + # Set account on request request.account = account return (user, token) diff --git a/backend/igny8_core/auth/middleware.py b/backend/igny8_core/auth/middleware.py index f738a4b8..5b82fb26 100644 --- a/backend/igny8_core/auth/middleware.py +++ b/backend/igny8_core/auth/middleware.py @@ -99,14 +99,21 @@ class AccountContextMiddleware(MiddlewareMixin): user = User.objects.select_related('account', 'account__plan').get(id=user_id) request.user = user if account_id: - # Unified API Standard: Extract account_id from JWT, load Account object, set request.account - # Token's account_id is authoritative - no validation against user.account - try: - account = Account.objects.get(id=account_id) + # Verify account still exists + account = Account.objects.get(id=account_id) + # Token's account_id is authoritative for current context + # For developers/admins, they can access any account + # For regular users, verify they belong to this account + if not user.is_admin_or_developer() and not user.is_system_account_user(): + # Regular user - must belong to this account + if user.account and user.account.id != account_id: + # User doesn't belong to token's account - use user's account instead + request.account = user.account + else: + request.account = account + else: + # Developer/admin/system user - use token's account (they can access any) request.account = account - except Account.DoesNotExist: - # Account from token doesn't exist - set to None - request.account = None else: try: user_account = getattr(user, 'account', None) From 37a64fa1efe3cf7567b7bc30653b57918fb2ac67 Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 17 Nov 2025 01:35:30 +0500 Subject: [PATCH 31/35] Revert "Fix authentication: Use token's account_id as authoritative source" This reverts commit 46b5b5f1b2f9ba4d4f1dfffae2045758ac649339. --- backend/igny8_core/api/authentication.py | 14 +++++--------- backend/igny8_core/auth/middleware.py | 16 ++++------------ 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/backend/igny8_core/api/authentication.py b/backend/igny8_core/api/authentication.py index 723abeef..e3930a88 100644 --- a/backend/igny8_core/api/authentication.py +++ b/backend/igny8_core/api/authentication.py @@ -62,20 +62,16 @@ class JWTAuthentication(BaseAuthentication): # User not found - return None to allow other auth classes to try return None - # Get account from token (token's account_id is authoritative for current context) + # Get account from token account_id = payload.get('account_id') account = None if account_id: try: account = Account.objects.get(id=account_id) - # Verify user has access to this account - # For developers/admins, they can access any account - # For regular users, verify they belong to this account - if not user.is_admin_or_developer() and not user.is_system_account_user(): - # Regular user - must belong to this account - if user.account and user.account.id != account_id: - # User doesn't belong to token's account - use user's account instead - account = user.account + # If user's account changed, use the new one from user object (most up-to-date) + # This ensures we always use the user's current account, not a stale token account_id + if user.account and user.account.id != account_id: + account = user.account except Account.DoesNotExist: # Account from token doesn't exist - use user's account instead pass diff --git a/backend/igny8_core/auth/middleware.py b/backend/igny8_core/auth/middleware.py index 5b82fb26..1bcdd16f 100644 --- a/backend/igny8_core/auth/middleware.py +++ b/backend/igny8_core/auth/middleware.py @@ -99,20 +99,12 @@ class AccountContextMiddleware(MiddlewareMixin): user = User.objects.select_related('account', 'account__plan').get(id=user_id) request.user = user if account_id: - # Verify account still exists + # Verify account still exists and matches user account = Account.objects.get(id=account_id) - # Token's account_id is authoritative for current context - # For developers/admins, they can access any account - # For regular users, verify they belong to this account - if not user.is_admin_or_developer() and not user.is_system_account_user(): - # Regular user - must belong to this account - if user.account and user.account.id != account_id: - # User doesn't belong to token's account - use user's account instead - request.account = user.account - else: - request.account = account + # If user's account changed, use the new one from user object + if user.account and user.account.id != account_id: + request.account = user.account else: - # Developer/admin/system user - use token's account (they can access any) request.account = account else: try: From 1531f4122620a9831067880bb10242080777c74a Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 17 Nov 2025 01:35:34 +0500 Subject: [PATCH 32/35] Revert "Fix authentication: Ensure correct user/account is loaded" This reverts commit a267fc071522525ccc79c53992a87d367c998a53. --- backend/igny8_core/api/authentication.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/backend/igny8_core/api/authentication.py b/backend/igny8_core/api/authentication.py index e3930a88..f20ec8a5 100644 --- a/backend/igny8_core/api/authentication.py +++ b/backend/igny8_core/api/authentication.py @@ -55,9 +55,7 @@ class JWTAuthentication(BaseAuthentication): return None try: - # Refresh user from DB with account and plan relationships to get latest data - # This ensures changes to account/plan are reflected immediately without re-login - user = User.objects.select_related('account', 'account__plan').get(id=user_id) + user = User.objects.get(id=user_id) except User.DoesNotExist: # User not found - return None to allow other auth classes to try return None @@ -68,12 +66,7 @@ class JWTAuthentication(BaseAuthentication): if account_id: try: account = Account.objects.get(id=account_id) - # If user's account changed, use the new one from user object (most up-to-date) - # This ensures we always use the user's current account, not a stale token account_id - if user.account and user.account.id != account_id: - account = user.account except Account.DoesNotExist: - # Account from token doesn't exist - use user's account instead pass if not account: From fc6dd5623a92e7a79eef15defebd4009ddd4e0de Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 21:06:22 +0000 Subject: [PATCH 33/35] Add refresh token functionality and improve login response handling - Introduced RefreshTokenView to allow users to refresh their access tokens using a valid refresh token. - Enhanced LoginView to ensure correct user/account loading and improved error handling during user serialization. - Updated API response structure to include access and refresh token expiration times. - Adjusted frontend API handling to support both new and legacy token response formats. --- backend/igny8_core/auth/urls.py | 105 ++++++++++++++++-- backend/igny8_core/auth/views.py | 10 +- backend/igny8_core/modules/billing/views.py | 4 +- .../modules/system/settings_views.py | 19 +++- backend/igny8_core/modules/system/urls.py | 15 ++- docs/API-COMPLETE-REFERENCE.md | 7 +- frontend/src/services/api.ts | 7 +- frontend/src/store/authStore.ts | 21 ++-- 8 files changed, 155 insertions(+), 33 deletions(-) diff --git a/backend/igny8_core/auth/urls.py b/backend/igny8_core/auth/urls.py index 9209c60f..37345538 100644 --- a/backend/igny8_core/auth/urls.py +++ b/backend/igny8_core/auth/urls.py @@ -14,8 +14,10 @@ from .views import ( SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet, IndustryViewSet, SeedKeywordViewSet ) -from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer +from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer, RefreshTokenSerializer from .models import User +from .utils import generate_access_token, get_token_expiry, decode_token +import jwt router = DefaultRouter() # Main structure: Groups, Users, Accounts, Subscriptions, Site User Access @@ -78,7 +80,7 @@ class LoginView(APIView): password = serializer.validated_data['password'] try: - user = User.objects.get(email=email) + user = User.objects.select_related('account', 'account__plan').get(email=email) except User.DoesNotExist: return error_response( error='Invalid credentials', @@ -107,9 +109,17 @@ class LoginView(APIView): user_data = user_serializer.data except Exception as e: # Fallback if serializer fails (e.g., missing account_id column) + # Log the error for debugging but don't fail the login + import logging + logger = logging.getLogger(__name__) + logger.warning(f"UserSerializer failed for user {user.id}: {e}", exc_info=True) + + # Ensure username is properly set (use email prefix if username is empty/default) + username = user.username if user.username and user.username != 'user' else user.email.split('@')[0] + user_data = { 'id': user.id, - 'username': user.username, + 'username': username, 'email': user.email, 'role': user.role, 'account': None, @@ -119,12 +129,10 @@ class LoginView(APIView): return success_response( data={ 'user': user_data, - 'tokens': { - 'access': access_token, - 'refresh': refresh_token, - 'access_expires_at': access_expires_at.isoformat(), - 'refresh_expires_at': refresh_expires_at.isoformat(), - } + 'access': access_token, + 'refresh': refresh_token, + 'access_expires_at': access_expires_at.isoformat(), + 'refresh_expires_at': refresh_expires_at.isoformat(), }, message='Login successful', request=request @@ -180,6 +188,84 @@ class ChangePasswordView(APIView): ) +@extend_schema( + tags=['Authentication'], + summary='Refresh Token', + description='Refresh access token using refresh token' +) +class RefreshTokenView(APIView): + """Refresh access token endpoint.""" + permission_classes = [permissions.AllowAny] + + def post(self, request): + serializer = RefreshTokenSerializer(data=request.data) + if not serializer.is_valid(): + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + refresh_token = serializer.validated_data['refresh'] + + try: + # Decode and validate refresh token + payload = decode_token(refresh_token) + + # Verify it's a refresh token + if payload.get('type') != 'refresh': + return error_response( + error='Invalid token type', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get user + user_id = payload.get('user_id') + account_id = payload.get('account_id') + + try: + user = User.objects.select_related('account', 'account__plan').get(id=user_id) + except User.DoesNotExist: + return error_response( + error='User not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + + # Get account + account = None + if account_id: + try: + from .models import Account + account = Account.objects.get(id=account_id) + except Exception: + pass + + if not account: + account = getattr(user, 'account', None) + + # Generate new access token + access_token = generate_access_token(user, account) + access_expires_at = get_token_expiry('access') + + return success_response( + data={ + 'access': access_token, + 'access_expires_at': access_expires_at.isoformat() + }, + request=request + ) + + except jwt.InvalidTokenError: + return error_response( + error='Invalid or expired refresh token', + status_code=status.HTTP_401_UNAUTHORIZED, + request=request + ) + + @extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint class MeView(APIView): """Get current user information.""" @@ -201,6 +287,7 @@ urlpatterns = [ path('', include(router.urls)), path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'), path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'), + path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'), path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'), path('me/', MeView.as_view(), name='auth-me'), ] diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 4070e063..089972ba 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -933,12 +933,10 @@ class AuthViewSet(viewsets.GenericViewSet): return success_response( data={ 'user': user_serializer.data, - 'tokens': { - 'access': access_token, - 'refresh': refresh_token, - 'access_expires_at': access_expires_at.isoformat(), - 'refresh_expires_at': refresh_expires_at.isoformat(), - } + 'access': access_token, + 'refresh': refresh_token, + 'access_expires_at': access_expires_at.isoformat(), + 'refresh_expires_at': refresh_expires_at.isoformat(), }, message='Login successful', request=request diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index a8b31973..b86436cb 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -54,8 +54,8 @@ class CreditBalanceViewSet(viewsets.ViewSet): request=request ) - # Get plan credits per month - plan_credits_per_month = account.plan.credits_per_month if account.plan else 0 + # Get plan credits per month (use get_effective_credits_per_month for Phase 0 compatibility) + plan_credits_per_month = account.plan.get_effective_credits_per_month() if account.plan else 0 # Calculate credits used this month now = timezone.now() diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 51422645..5ded65c1 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -235,6 +235,15 @@ class ModuleSettingsViewSet(AccountModelViewSet): def retrieve(self, request, pk=None): """Get setting by key (pk can be key string)""" + # Special case: if pk is "enable", this is likely a routing conflict + # The correct endpoint is /settings/modules/enable/ which should go to ModuleEnableSettingsViewSet + if pk == 'enable': + return error_response( + error='Use /api/v1/system/settings/modules/enable/ endpoint for module enable settings', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + queryset = self.get_queryset() try: # Try to get by ID first @@ -301,7 +310,7 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): Allow read access to all authenticated users, but restrict write access to admins/owners """ - if self.action in ['list', 'retrieve']: + if self.action in ['list', 'retrieve', 'get_current']: permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] else: permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] @@ -321,6 +330,14 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): queryset = queryset.filter(account=account) return queryset + @action(detail=False, methods=['get', 'put'], url_path='current', url_name='current') + def get_current(self, request): + """Get or update current account's module enable settings""" + if request.method == 'GET': + return self.list(request) + else: + return self.update(request, pk=None) + def list(self, request, *args, **kwargs): """Get or create module enable settings for current account""" try: diff --git a/backend/igny8_core/modules/system/urls.py b/backend/igny8_core/modules/system/urls.py index c03c49b3..9c1508b2 100644 --- a/backend/igny8_core/modules/system/urls.py +++ b/backend/igny8_core/modules/system/urls.py @@ -16,8 +16,8 @@ router.register(r'strategies', StrategyViewSet, basename='strategy') router.register(r'settings/system', SystemSettingsViewSet, basename='system-settings') router.register(r'settings/account', AccountSettingsViewSet, basename='account-settings') router.register(r'settings/user', UserSettingsViewSet, basename='user-settings') +# Register ModuleSettingsViewSet first router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings') -router.register(r'settings/modules/enable', ModuleEnableSettingsViewSet, basename='module-enable-settings') router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings') # Custom URL patterns for integration settings - matching reference plugin structure @@ -50,7 +50,20 @@ integration_image_gen_settings_viewset = IntegrationSettingsViewSet.as_view({ 'get': 'get_image_generation_settings', }) +# Custom view for module enable settings to avoid URL routing conflict with ModuleSettingsViewSet +# This must be defined as a custom path BEFORE router.urls to ensure it matches first +# The update method handles pk=None correctly, so we can use as_view +module_enable_viewset = ModuleEnableSettingsViewSet.as_view({ + 'get': 'list', + 'put': 'update', + 'patch': 'partial_update', +}) + urlpatterns = [ + # Module enable settings endpoint - MUST come before router.urls to avoid conflict + # When /settings/modules/enable/ is called, it would match ModuleSettingsViewSet with pk='enable' + # So we define it as a custom path first + path('settings/modules/enable/', module_enable_viewset, name='module-enable-settings'), path('', include(router.urls)), # Public health check endpoint (API Standard v1.0 requirement) path('ping/', ping, name='system-ping'), diff --git a/docs/API-COMPLETE-REFERENCE.md b/docs/API-COMPLETE-REFERENCE.md index 1e6cc6f4..9c8b2b4c 100644 --- a/docs/API-COMPLETE-REFERENCE.md +++ b/docs/API-COMPLETE-REFERENCE.md @@ -644,9 +644,12 @@ class KeywordViewSet(SiteSectorModelViewSet): "data": { "user": { ... }, "access": "eyJ0eXAiOiJKV1QiLCJhbGc...", - "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..." + "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...", + "access_expires_at": "2025-01-XXT...", + "refresh_expires_at": "2025-01-XXT..." }, - "message": "Login successful" + "message": "Login successful", + "request_id": "550e8400-e29b-41d4-a716-446655440000" } ``` diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4ec8d50e..4a66392c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -194,13 +194,14 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo if (refreshResponse.ok) { const refreshData = await refreshResponse.json(); - if (refreshData.success && refreshData.access) { + const accessToken = refreshData.data?.access || refreshData.access; + if (refreshData.success && accessToken) { // Update token in store try { const authStorage = localStorage.getItem('auth-storage'); if (authStorage) { const parsed = JSON.parse(authStorage); - parsed.state.token = refreshData.access; + parsed.state.token = accessToken; localStorage.setItem('auth-storage', JSON.stringify(parsed)); } } catch (e) { @@ -210,7 +211,7 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo // Retry original request with new token const newHeaders = { ...headers, - 'Authorization': `Bearer ${refreshData.access}`, + 'Authorization': `Bearer ${accessToken}`, }; const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, { diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 7b765f42..ecb35b0f 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -60,14 +60,17 @@ export const useAuthStore = create()( const data = await response.json(); if (!response.ok || !data.success) { - throw new Error(data.message || 'Login failed'); + throw new Error(data.error || data.message || 'Login failed'); } - // Store user and JWT tokens + // Store user and JWT tokens (handle both old and new API formats) + const responseData = data.data || data; + // Support both formats: new (access/refresh at top level) and old (tokens.access/refresh) + const tokens = responseData.tokens || {}; set({ - user: data.user, - token: data.tokens?.access || null, - refreshToken: data.tokens?.refresh || null, + user: responseData.user || data.user, + token: responseData.access || tokens.access || data.access || null, + refreshToken: responseData.refresh || tokens.refresh || data.refresh || null, isAuthenticated: true, loading: false }); @@ -119,8 +122,8 @@ export const useAuthStore = create()( // Store user and JWT tokens set({ user: data.user, - token: data.tokens?.access || null, - refreshToken: data.tokens?.refresh || null, + token: data.data?.access || data.access || null, + refreshToken: data.data?.refresh || data.refresh || null, isAuthenticated: true, loading: false }); @@ -168,8 +171,8 @@ export const useAuthStore = create()( throw new Error(data.message || 'Token refresh failed'); } - // Update access token - set({ token: data.access }); + // Update access token (API returns access at top level of data) + set({ token: data.data?.access || data.access }); // Also refresh user data to get latest account/plan information // This ensures account/plan changes are reflected immediately From 51cd021f8529379be5a32234b657cd1c808cd847 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 21:16:35 +0000 Subject: [PATCH 34/35] fixed all phase 0 issues Enhance error handling for ModuleEnableSettings retrieval - Added a check for the existence of the ModuleEnableSettings table before attempting to retrieve or fixed all phase 0 create settings for an account. - Implemented logging and a user-friendly error response if the table does not exist, prompting the user to run the necessary migration. - Updated migration to create the ModuleEnableSettings table using raw SQL to avoid model resolution issues. --- .../0007_add_module_enable_settings.py | 50 ++++++++++--------- .../modules/system/settings_views.py | 32 +++++++++--- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/backend/igny8_core/modules/system/migrations/0007_add_module_enable_settings.py b/backend/igny8_core/modules/system/migrations/0007_add_module_enable_settings.py index 2a03b28d..ca1b8085 100644 --- a/backend/igny8_core/modules/system/migrations/0007_add_module_enable_settings.py +++ b/backend/igny8_core/modules/system/migrations/0007_add_module_enable_settings.py @@ -1,37 +1,39 @@ # Generated manually for Phase 0: Module Enable Settings +# Using RunSQL to create table directly to avoid model resolution issues with new unified API model -from django.db import migrations, models +from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('igny8_core_modules_system', '0006_alter_systemstatus_unique_together_and_more'), + ('system', '0006_alter_systemstatus_unique_together_and_more'), ('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'), ] operations = [ - migrations.CreateModel( - name='ModuleEnableSettings', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('planner_enabled', models.BooleanField(default=True, help_text='Enable Planner module')), - ('writer_enabled', models.BooleanField(default=True, help_text='Enable Writer module')), - ('thinker_enabled', models.BooleanField(default=True, help_text='Enable Thinker module')), - ('automation_enabled', models.BooleanField(default=True, help_text='Enable Automation module')), - ('site_builder_enabled', models.BooleanField(default=True, help_text='Enable Site Builder module')), - ('linker_enabled', models.BooleanField(default=True, help_text='Enable Linker module')), - ('optimizer_enabled', models.BooleanField(default=True, help_text='Enable Optimizer module')), - ('publisher_enabled', models.BooleanField(default=True, help_text='Enable Publisher module')), - ('account', models.ForeignKey(on_delete=models.CASCADE, to='igny8_core_auth.account', db_column='tenant_id')), - ], - options={ - 'db_table': 'igny8_module_enable_settings', - }, - ), - migrations.AddConstraint( - model_name='moduleenablesettings', - constraint=models.UniqueConstraint(fields=('account',), name='unique_account_module_enable_settings'), + # Create table using raw SQL to avoid model resolution issues + # The model state is automatically discovered from models.py + migrations.RunSQL( + sql=""" + CREATE TABLE IF NOT EXISTS igny8_module_enable_settings ( + id BIGSERIAL PRIMARY KEY, + planner_enabled BOOLEAN NOT NULL DEFAULT TRUE, + writer_enabled BOOLEAN NOT NULL DEFAULT TRUE, + thinker_enabled BOOLEAN NOT NULL DEFAULT TRUE, + automation_enabled BOOLEAN NOT NULL DEFAULT TRUE, + site_builder_enabled BOOLEAN NOT NULL DEFAULT TRUE, + linker_enabled BOOLEAN NOT NULL DEFAULT TRUE, + optimizer_enabled BOOLEAN NOT NULL DEFAULT TRUE, + publisher_enabled BOOLEAN NOT NULL DEFAULT TRUE, + tenant_id BIGINT NOT NULL REFERENCES igny8_tenants(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_tenant_id_idx ON igny8_module_enable_settings(tenant_id); + CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_account_created_idx ON igny8_module_enable_settings(tenant_id, created_at); + CREATE UNIQUE INDEX IF NOT EXISTS unique_account_module_enable_settings ON igny8_module_enable_settings(tenant_id); + """, + reverse_sql="DROP TABLE IF EXISTS igny8_module_enable_settings CASCADE;", ), ] - diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 5ded65c1..57a0b0f0 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -354,15 +354,31 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet): request=request ) - # Get or create settings for account (one per account) + # Check if table exists (migration might not have been run) try: - settings = ModuleEnableSettings.objects.get(account=account) - except ModuleEnableSettings.DoesNotExist: - # Create default settings for account - settings = ModuleEnableSettings.objects.create(account=account) - - serializer = self.get_serializer(settings) - return success_response(data=serializer.data, request=request) + # Get or create settings for account (one per account) + try: + settings = ModuleEnableSettings.objects.get(account=account) + except ModuleEnableSettings.DoesNotExist: + # Create default settings for account + settings = ModuleEnableSettings.objects.create(account=account) + + serializer = self.get_serializer(settings) + return success_response(data=serializer.data, request=request) + except Exception as db_error: + # Check if it's a "table does not exist" error + error_str = str(db_error) + if 'does not exist' in error_str.lower() or 'relation' in error_str.lower(): + import logging + logger = logging.getLogger(__name__) + logger.error(f"ModuleEnableSettings table does not exist. Migration 0007_add_module_enable_settings needs to be run: {error_str}") + return error_response( + error='Module enable settings table not found. Please run migration: python manage.py migrate igny8_core_modules_system 0007', + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + request=request + ) + # Re-raise other database errors + raise except Exception as e: import traceback error_trace = traceback.format_exc() From 56c30e4904709f86e43eab4ad61ea73613e2d1ee Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 21:21:07 +0000 Subject: [PATCH 35/35] schedules page removed --- docs/03-FRONTEND-ARCHITECTURE.md | 4 ++-- docs/planning/IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md | 5 ++--- docs/planning/IGNY8-IMPLEMENTATION-PLAN.md | 2 +- docs/planning/phases/PHASE-2-AUTOMATION-SYSTEM.md | 12 +++++------- frontend/src/App.tsx | 6 ------ frontend/src/components/header/SiteSwitcher.tsx | 2 -- frontend/src/config/routes.config.ts | 5 ----- frontend/src/layout/AppSidebar.tsx | 7 ------- frontend/src/pages/Help/Help.tsx | 4 ++-- 9 files changed, 12 insertions(+), 35 deletions(-) diff --git a/docs/03-FRONTEND-ARCHITECTURE.md b/docs/03-FRONTEND-ARCHITECTURE.md index 4e5265a9..30c32364 100644 --- a/docs/03-FRONTEND-ARCHITECTURE.md +++ b/docs/03-FRONTEND-ARCHITECTURE.md @@ -411,9 +411,9 @@ frontend/ } /> } /> - {/* Automation & Schedules */} + {/* Automation */} } /> - } /> + {/* Note: Schedules functionality is integrated into Automation Dashboard */} {/* Settings */} } /> diff --git a/docs/planning/IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md b/docs/planning/IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md index 3ea37567..8646d50d 100644 --- a/docs/planning/IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md +++ b/docs/planning/IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md @@ -278,11 +278,10 @@ frontend/src/ │ ├── Billing/ # Existing │ ├── Settings/ # Existing │ ├── Automation/ # EXISTING (placeholder) - IMPLEMENT -│ │ ├── Dashboard.tsx # Automation overview +│ │ ├── Dashboard.tsx # Automation overview (includes schedules functionality) │ │ ├── Rules.tsx # Automation rules management │ │ ├── Workflows.tsx # Workflow templates │ │ └── History.tsx # Automation execution history -│ ├── Schedules.tsx # EXISTING (placeholder) - IMPLEMENT │ ├── Linker/ # NEW │ │ ├── Dashboard.tsx │ │ ├── Candidates.tsx @@ -653,7 +652,7 @@ docker-data/ | **Implement Automation Service** | `domain/automation/services/` | TODO | HIGH | | **Implement Automation API** | `modules/automation/` | TODO | HIGH | | **Implement Automation UI** | `frontend/src/pages/Automation/` | TODO | HIGH | -| **Implement Schedules UI** | `frontend/src/pages/Schedules.tsx` | TODO | HIGH | +| **Note**: Schedules functionality will be integrated into Automation UI, not as a separate page | - | - | - | ### 9.2 Phase 1: Site Builder diff --git a/docs/planning/IGNY8-IMPLEMENTATION-PLAN.md b/docs/planning/IGNY8-IMPLEMENTATION-PLAN.md index 8e596ee0..ac2ed207 100644 --- a/docs/planning/IGNY8-IMPLEMENTATION-PLAN.md +++ b/docs/planning/IGNY8-IMPLEMENTATION-PLAN.md @@ -234,7 +234,7 @@ CREDIT_COSTS = { |------|-------|--------------| | **Automation Dashboard** | `frontend/src/pages/Automation/Dashboard.tsx` | EXISTING (placeholder) | | **Rules Management** | `frontend/src/pages/Automation/Rules.tsx` | NEW | -| **Schedules Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) | +| **Schedules (within Automation)** | Integrated into Automation Dashboard | Part of automation menu | | **Automation API Client** | `frontend/src/services/automation.api.ts` | NEW | ### 2.6 Testing diff --git a/docs/planning/phases/PHASE-2-AUTOMATION-SYSTEM.md b/docs/planning/phases/PHASE-2-AUTOMATION-SYSTEM.md index e8c8edc4..2726093b 100644 --- a/docs/planning/phases/PHASE-2-AUTOMATION-SYSTEM.md +++ b/docs/planning/phases/PHASE-2-AUTOMATION-SYSTEM.md @@ -462,13 +462,11 @@ urlpatterns = router.urls - Test rule - Manual execution -#### Schedules Page +#### Schedules (Part of Automation Menu) -| Task | File | Dependencies | Implementation | -|------|------|--------------|----------------| -| **Schedules Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) | View scheduled task history | +**Note**: Schedules functionality will be integrated into the Automation menu group, not as a separate page. -**Schedules Page Features**: +**Schedules Features** (within Automation Dashboard): - List scheduled tasks - Filter by status, rule, date - View execution results @@ -553,11 +551,11 @@ export const automationApi = { - [ ] Implement `frontend/src/pages/Automation/Dashboard.tsx` - [ ] Create `frontend/src/pages/Automation/Rules.tsx` -- [ ] Implement `frontend/src/pages/Schedules.tsx` +- [ ] Integrate schedules functionality into Automation Dashboard (not as separate page) - [ ] Create `frontend/src/services/automation.api.ts` - [ ] Create rule creation wizard - [ ] Create rule editor -- [ ] Create schedule history table +- [ ] Create schedule history table (within Automation Dashboard) ### Testing Tasks diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d09dbb0..ebf779f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,7 +50,6 @@ const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords")); const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries")); // Other Pages - Lazy loaded -const Schedules = lazy(() => import("./pages/Schedules")); const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard")); // Settings - Lazy loaded @@ -294,11 +293,6 @@ export default function App() { } /> - - - - } /> {/* Settings */} => { diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index fe1c2825..d2973aa0 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -11,7 +11,6 @@ import { PlugInIcon, TaskIcon, BoltIcon, - TimeIcon, DocsIcon, PageIcon, DollarLineIcon, @@ -144,12 +143,6 @@ const AppSidebar: React.FC = () => { }); } - workflowItems.push({ - icon: , - name: "Schedules", - path: "/schedules", - }); - return [ { label: "OVERVIEW", diff --git a/frontend/src/pages/Help/Help.tsx b/frontend/src/pages/Help/Help.tsx index 8e7f8f7f..2544f27d 100644 --- a/frontend/src/pages/Help/Help.tsx +++ b/frontend/src/pages/Help/Help.tsx @@ -76,7 +76,7 @@ export default function Help() { }, { question: "How do I set up automation?", - answer: "Go to Dashboard > Automation Setup section. Enable automation for each step (Keywords, Ideas, Content, Images) and configure settings like how many keywords to process per cycle. Advanced settings are available in Schedules page." + answer: "Go to Dashboard > Automation Setup section. Enable automation for each step (Keywords, Ideas, Content, Images) and configure settings like how many keywords to process per cycle. Advanced scheduling settings are available in the Automation menu." }, { question: "Can I edit AI-generated content?", @@ -539,7 +539,7 @@ export default function Help() {

- Note: Configure automation in Dashboard > Automation Setup. For advanced scheduling, go to Schedules page. + Note: Configure automation in Dashboard > Automation Setup. For advanced scheduling, go to the Automation menu.