26 Commits

Author SHA1 Message Date
IGNY8 VPS (Salman)
8a9dd44c50 branch 1st 2025-11-16 20:08:58 +00:00
IGNY8 VPS (Salman)
219dae83c6 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
2025-11-16 19:44:18 +00:00
IGNY8 VPS (Salman)
066b81dd2a 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
2025-11-16 19:40:44 +00:00
IGNY8 VPS (Salman)
8171014a7e Fix authentication: Follow unified API model - token account_id is authoritative
- 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
2025-11-16 19:36:18 +00:00
IGNY8 VPS (Salman)
46b5b5f1b2 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
2025-11-16 19:34:02 +00:00
IGNY8 VPS (Salman)
a267fc0715 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
2025-11-16 19:28:37 +00:00
IGNY8 VPS (Salman)
9ec8908091 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
2025-11-16 19:26:18 +00:00
IGNY8 VPS (Salman)
0d468ef15a 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
2025-11-16 19:25:36 +00:00
IGNY8 VPS (Salman)
8fc483251e 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
2025-11-16 19:25:05 +00:00
IGNY8 VPS (Salman)
1d39f3f00a 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
2025-11-16 19:22:45 +00:00
IGNY8 VPS (Salman)
b20fab8ec1 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)
2025-11-16 19:16:43 +00:00
IGNY8 VPS (Salman)
437b0c7516 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
2025-11-16 19:16:07 +00:00
IGNY8 VPS (Salman)
4de9128430 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
2025-11-16 19:14:53 +00:00
IGNY8 VPS (Salman)
f195b6a72a 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)
2025-11-16 19:13:12 +00:00
IGNY8 VPS (Salman)
ab6b6cc4be 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
2025-11-16 19:06:48 +00:00
IGNY8 VPS (Salman)
d0e6b342b5 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
2025-11-16 19:06:07 +00:00
IGNY8 VPS (Salman)
461f3211dd 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
2025-11-16 19:02:26 +00:00
IGNY8 VPS (Salman)
abbf6dbabb 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
2025-11-16 18:51:40 +00:00
IGNY8 VPS (Salman)
a10e89ab08 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
2025-11-16 18:50:24 +00:00
IGNY8 VPS (Salman)
5842ca2dfc Phase 0: Fix AppSidebar useEffect for module settings loading 2025-11-16 18:48:45 +00:00
IGNY8 VPS (Salman)
9b3fb25bc9 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
2025-11-16 18:48:23 +00:00
IGNY8 VPS (Salman)
dbe8da589f 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
2025-11-16 18:44:07 +00:00
IGNY8 VPS (Salman)
8102aa74eb 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
2025-11-16 18:43:24 +00:00
IGNY8 VPS (Salman)
13bd7fa134 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
2025-11-16 18:40:10 +00:00
IGNY8 VPS (Salman)
a73b2ae22b 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
2025-11-16 18:39:16 +00:00
IGNY8 VPS (Salman)
5b11c4001e 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
2025-11-16 18:37:41 +00:00
28 changed files with 1482 additions and 517 deletions

Binary file not shown.

View File

@@ -192,6 +192,33 @@ class AIEngine:
self.step_tracker.add_request_step("PREP", "success", prep_message) self.step_tracker.add_request_step("PREP", "success", prep_message)
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta()) 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
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 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:
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%) # Phase 3: AI_CALL - Provider API Call (25-70%)
# Validate account exists before proceeding # Validate account exists before proceeding
if not self.account: if not self.account:
@@ -325,37 +352,45 @@ class AIEngine:
# Store save_msg for use in DONE phase # Store save_msg for use in DONE phase
final_save_msg = save_msg 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: if self.account and raw_response:
try: try:
from igny8_core.modules.billing.services import CreditService 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) # Map function name to operation type
credits_used = self._calculate_credits_for_clustering( operation_type = self._get_operation_type(function_name)
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)
)
# Log credit usage (don't deduct from account.credits, just log) # Calculate actual amount based on results
CreditUsageLog.objects.create( 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, account=self.account,
operation_type='clustering', operation_type=operation_type,
credits_used=credits_used, amount=actual_amount,
cost_usd=raw_response.get('cost'), cost_usd=raw_response.get('cost'),
model_used=raw_response.get('model', ''), model_used=raw_response.get('model', ''),
tokens_input=raw_response.get('tokens_input', 0), tokens_input=raw_response.get('tokens_input', 0),
tokens_output=raw_response.get('tokens_output', 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={ metadata={
'function_name': function_name,
'clusters_created': clusters_created, 'clusters_created': clusters_created,
'keywords_updated': keywords_updated, '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: 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%) # Phase 6: DONE - Finalization (98-100%)
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully" success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
@@ -453,18 +488,74 @@ class AIEngine:
# Don't fail the task if logging fails # Don't fail the task if logging fails
logger.warning(f"Failed to log to database: {e}") logger.warning(f"Failed to log to database: {e}")
def _calculate_credits_for_clustering(self, keyword_count, tokens, cost): def _get_operation_type(self, function_name):
"""Calculate credits used for clustering operation""" """Map function name to operation type for credit system"""
# Use plan's cost per request if available, otherwise calculate from tokens mapping = {
if self.account and hasattr(self.account, 'plan') and self.account.plan: 'auto_cluster': 'clustering',
plan = self.account.plan 'generate_ideas': 'idea_generation',
# Check if plan has ai_cost_per_request config 'generate_content': 'content_generation',
if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request: 'generate_image_prompts': 'image_prompt_extraction',
cluster_cost = plan.ai_cost_per_request.get('cluster', 0) 'generate_images': 'image_generation',
if cluster_cost: }
return int(cluster_cost) return mapping.get(function_name, function_name)
# Fallback: 1 credit per 30 keywords (minimum 1) def _get_estimated_amount(self, function_name, data, payload):
credits = max(1, int(keyword_count / 30)) """Get estimated amount for credit calculation (before operation)"""
return credits 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')

View File

@@ -19,21 +19,9 @@ class PlanAdmin(admin.ModelAdmin):
('Plan Info', { ('Plan Info', {
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active') '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') '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', { ('Billing & Credits', {
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month') 'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
}), }),

View File

@@ -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',
),
]

View File

@@ -93,8 +93,8 @@ class Account(models.Model):
class Plan(models.Model): class Plan(models.Model):
""" """
Subscription plan model with comprehensive limits and features. Subscription plan model - Phase 0: Credit-only system.
Plans define limits for users, sites, content generation, AI usage, and billing. Plans define credits, billing, and account management limits only.
""" """
BILLING_CYCLE_CHOICES = [ BILLING_CYCLE_CHOICES = [
('monthly', 'Monthly'), ('monthly', 'Monthly'),
@@ -110,7 +110,7 @@ class Plan(models.Model):
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=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_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account")
max_sites = models.IntegerField( max_sites = models.IntegerField(
default=1, 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_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") max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
# Planner Limits # Billing & Credits (Phase 0: Credit-only system)
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
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included") 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") 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?") allow_credit_topup = models.BooleanField(default=True, help_text="Can user purchase more credits?")

View File

@@ -11,10 +11,10 @@ class PlanSerializer(serializers.ModelSerializer):
model = Plan model = Plan
fields = [ fields = [
'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active', 'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active',
'max_users', 'max_sites', 'max_keywords', 'max_clusters', 'max_content_ideas', 'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count', 'included_credits', 'extra_credit_price', 'allow_credit_topup',
'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit', 'auto_credit_topup_threshold', 'auto_credit_topup_amount',
'included_credits', 'image_model_choices', 'credits_per_month' 'stripe_product_id', 'stripe_price_id', 'credits_per_month'
] ]

View File

@@ -3,6 +3,7 @@ Celery configuration for IGNY8
""" """
import os import os
from celery import Celery from celery import Celery
from celery.schedules import crontab
# Set the default Django settings module for the 'celery' program. # Set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings') 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. # Load task modules from all registered Django apps.
app.autodiscover_tasks() 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) @app.task(bind=True, ignore_result=True)
def debug_task(self): def debug_task(self):

View File

@@ -1,22 +1,21 @@
""" """
Credit Cost Constants Credit Cost Constants
Phase 0: Credit-only system costs per operation
""" """
CREDIT_COSTS = { CREDIT_COSTS = {
'clustering': { 'clustering': 10, # Per clustering request
'base': 1, # 1 credit per 30 keywords 'idea_generation': 15, # Per cluster → ideas request
'per_keyword': 1 / 30, 'content_generation': 1, # Per 100 words
}, 'image_prompt_extraction': 2, # Per content piece
'ideas': { 'image_generation': 5, # Per image
'base': 1, # 1 credit per idea 'linking': 8, # Per content piece (NEW)
}, 'optimization': 1, # Per 200 words (NEW)
'content': { 'site_structure_generation': 50, # Per site blueprint (NEW)
'base': 3, # 3 credits per full blog post 'site_page_generation': 20, # Per page (NEW)
}, # Legacy operation types (for backward compatibility)
'images': { 'ideas': 15, # Alias for idea_generation
'base': 1, # 1 credit per image 'content': 3, # Legacy: 3 credits per content piece
}, 'images': 5, # Alias for image_generation
'reparse': { 'reparse': 1, # Per reparse
'base': 1, # 1 credit per reparse
},
} }

View File

@@ -13,17 +13,121 @@ class CreditService:
"""Service for managing credits""" """Service for managing credits"""
@staticmethod @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, user=None):
"""
Check if account has sufficient credits for an operation.
Args: Args:
account: Account instance account: Account instance
required_credits: Number of credits required operation_type: Type of operation
amount: Optional amount (word count, image count, etc.)
user: Optional user instance (for developer/admin bypass)
Raises: Raises:
InsufficientCreditsError: If account doesn't have enough credits 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(
f"Insufficient credits. Required: {required}, Available: {account.credits}"
)
return True
@staticmethod
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: if account.credits < required_credits:
raise InsufficientCreditsError( raise InsufficientCreditsError(
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}" f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
@@ -51,8 +155,8 @@ class CreditService:
Returns: Returns:
int: New credit balance int: New credit balance
""" """
# Check sufficient credits # Check sufficient credits (legacy: amount is already calculated)
CreditService.check_credits(account, amount) CreditService.check_credits_legacy(account, amount)
# Deduct from account.credits # Deduct from account.credits
account.credits -= amount account.credits -= amount
@@ -84,6 +188,61 @@ class CreditService:
return account.credits 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 @staticmethod
@transaction.atomic @transaction.atomic
def add_credits(account, amount, transaction_type, description, metadata=None): def add_credits(account, amount, transaction_type, description, metadata=None):
@@ -120,6 +279,7 @@ class CreditService:
def calculate_credits_for_operation(operation_type, **kwargs): def calculate_credits_for_operation(operation_type, **kwargs):
""" """
Calculate credits needed for an operation. Calculate credits needed for an operation.
Legacy method - use get_credit_cost() instead.
Args: Args:
operation_type: Type of operation operation_type: Type of operation
@@ -131,31 +291,22 @@ class CreditService:
Raises: Raises:
CreditCalculationError: If calculation fails CreditCalculationError: If calculation fails
""" """
if operation_type not in CREDIT_COSTS: # Map legacy operation types
raise CreditCalculationError(f"Unknown operation type: {operation_type}") if operation_type == 'ideas':
operation_type = 'idea_generation'
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
elif operation_type == 'content': elif operation_type == 'content':
# 3 credits per content piece operation_type = 'content_generation'
content_count = kwargs.get('content_count', 1)
return cost_config['base'] * content_count
elif operation_type == 'images': elif operation_type == 'images':
# 1 credit per image operation_type = 'image_generation'
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']
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)

View File

@@ -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
}

View File

@@ -207,7 +207,10 @@ class CreditUsageViewSet(AccountModelViewSet):
@action(detail=False, methods=['get'], url_path='limits', url_name='limits') @action(detail=False, methods=['get'], url_path='limits', url_name='limits')
def limits(self, request): 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 # Try multiple ways to get account
account = getattr(request, 'account', None) account = getattr(request, 'account', None)
@@ -225,13 +228,7 @@ class CreditUsageViewSet(AccountModelViewSet):
except (AttributeError, UserModel.DoesNotExist, Exception) as e: except (AttributeError, UserModel.DoesNotExist, Exception) as e:
account = None 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: 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 empty limits instead of error - frontend will show "no data" message
return success_response(data={'limits': []}, request=request) return success_response(data={'limits': []}, request=request)
@@ -241,115 +238,16 @@ class CreditUsageViewSet(AccountModelViewSet):
return success_response(data={'limits': []}, request=request) return success_response(data={'limits': []}, request=request)
# Import models # 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 from igny8_core.auth.models import User, Site
# Get current month boundaries # Get current month boundaries
now = timezone.now() now = timezone.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) 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 # Calculate usage statistics
limits_data = [] limits_data = []
# Planner Limits # Credit Usage (Phase 0: Credit-only system)
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
credits_used_month = CreditUsageLog.objects.filter( credits_used_month = CreditUsageLog.objects.filter(
account=account, account=account,
created_at__gte=start_of_month created_at__gte=start_of_month
@@ -358,64 +256,89 @@ class CreditUsageViewSet(AccountModelViewSet):
# Get credits by operation type # Get credits by operation type
cluster_credits = CreditUsageLog.objects.filter( cluster_credits = CreditUsageLog.objects.filter(
account=account, account=account,
operation_type='clustering', operation_type__in=['clustering'],
created_at__gte=start_of_month created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0 ).aggregate(total=Sum('credits_used'))['total'] or 0
content_credits = CreditUsageLog.objects.filter( content_credits = CreditUsageLog.objects.filter(
account=account, account=account,
operation_type='content', operation_type__in=['content', 'content_generation'],
created_at__gte=start_of_month created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0 ).aggregate(total=Sum('credits_used'))['total'] or 0
image_credits = CreditUsageLog.objects.filter( image_credits = CreditUsageLog.objects.filter(
account=account, account=account,
operation_type='image', operation_type__in=['images', 'image_generation', 'image_prompt_extraction'],
created_at__gte=start_of_month created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0 ).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([ limits_data.extend([
{ {
'title': 'Monthly AI Credits', 'title': 'Monthly Credits',
'limit': plan_credits, 'limit': plan_credits,
'used': credits_used_month, 'used': credits_used_month,
'available': max(0, plan_credits - credits_used_month), 'available': max(0, plan_credits - credits_used_month),
'unit': 'credits', 'unit': 'credits',
'category': 'ai', 'category': 'credits',
'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0 'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0
}, },
{ {
'title': 'Content AI Credits', 'title': 'Current Balance',
'limit': plan.monthly_content_ai_credits or 0, 'limit': None, # No limit - shows current balance
'used': content_credits, 'used': None,
'available': max(0, (plan.monthly_content_ai_credits or 0) - content_credits), 'available': account.credits,
'unit': 'credits', 'unit': 'credits',
'category': 'ai', 'category': 'credits',
'percentage': (content_credits / (plan.monthly_content_ai_credits or 1)) * 100 if plan.monthly_content_ai_credits else 0 'percentage': None
}, },
{ {
'title': 'Image AI Credits', 'title': 'Clustering Credits',
'limit': plan.monthly_image_ai_credits or 0, 'limit': None,
'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,
'used': cluster_credits, 'used': cluster_credits,
'available': max(0, (plan.monthly_cluster_ai_credits or 0) - cluster_credits), 'available': None,
'unit': 'credits', 'unit': 'credits',
'category': 'ai', 'category': 'credits',
'percentage': (cluster_credits / (plan.monthly_cluster_ai_credits or 1)) * 100 if plan.monthly_cluster_ai_credits else 0 '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() users_count = User.objects.filter(account=account).count()
sites_count = Site.objects.filter(account=account).count() sites_count = Site.objects.filter(account=account).count()
@@ -426,7 +349,7 @@ class CreditUsageViewSet(AccountModelViewSet):
'used': users_count, 'used': users_count,
'available': max(0, (plan.max_users or 0) - users_count), 'available': max(0, (plan.max_users or 0) - users_count),
'unit': 'users', 'unit': 'users',
'category': 'general', 'category': 'account',
'percentage': (users_count / (plan.max_users or 1)) * 100 if plan.max_users else 0 '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, 'used': sites_count,
'available': max(0, (plan.max_sites or 0) - sites_count), 'available': max(0, (plan.max_sites or 0) - sites_count),
'unit': 'sites', 'unit': 'sites',
'category': 'general', 'category': 'account',
'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0 'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0
}, },
]) ])

View File

@@ -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'),
),
]

View File

@@ -6,7 +6,7 @@ from igny8_core.auth.models import AccountBaseModel
# Import settings models # Import settings models
from .settings_models import ( from .settings_models import (
SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
) )

View File

@@ -3,7 +3,7 @@ Settings Models Admin
""" """
from django.contrib import admin from django.contrib import admin
from igny8_core.admin.base import AccountAdminMixin 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) @admin.register(SystemSettings)

View File

@@ -92,6 +92,46 @@ class ModuleSettings(BaseSettings):
return f"ModuleSetting: {self.module_name} - {self.key}" 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) # AISettings extends IntegrationSettings (which already exists)
# We'll create it as a separate model that can reference IntegrationSettings # We'll create it as a separate model that can reference IntegrationSettings
class AISettings(AccountBaseModel): class AISettings(AccountBaseModel):

View File

@@ -2,7 +2,7 @@
Serializers for Settings Models Serializers for Settings Models
""" """
from rest_framework import serializers 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 from .validators import validate_settings_schema
@@ -58,6 +58,17 @@ class ModuleSettingsSerializer(serializers.ModelSerializer):
return value 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 AISettingsSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = AISettings model = AISettings

View File

@@ -13,10 +13,10 @@ from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAu
from igny8_core.api.pagination import CustomPageNumberPagination from igny8_core.api.pagination import CustomPageNumberPagination
from igny8_core.api.throttles import DebugScopedRateThrottle from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner 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 ( from .settings_serializers import (
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer, SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
ModuleSettingsSerializer, AISettingsSerializer ModuleSettingsSerializer, ModuleEnableSettingsSerializer, AISettingsSerializer
) )
@@ -276,6 +276,125 @@ class ModuleSettingsViewSet(AccountModelViewSet):
serializer.save(account=account) 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
Read access: All authenticated users
Write access: Admins/Owners only
"""
queryset = ModuleEnableSettings.objects.all()
serializer_class = ModuleEnableSettingsSerializer
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"""
# 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"""
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='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"""
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=f'Failed to load module enable settings: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
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( @extend_schema_view(
list=extend_schema(tags=['System']), list=extend_schema(tags=['System']),
create=extend_schema(tags=['System']), create=extend_schema(tags=['System']),

View File

@@ -7,7 +7,7 @@ from .views import AIPromptViewSet, AuthorProfileViewSet, StrategyViewSet, syste
from .integration_views import IntegrationSettingsViewSet from .integration_views import IntegrationSettingsViewSet
from .settings_views import ( from .settings_views import (
SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet, SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet,
ModuleSettingsViewSet, AISettingsViewSet ModuleSettingsViewSet, ModuleEnableSettingsViewSet, AISettingsViewSet
) )
router = DefaultRouter() router = DefaultRouter()
router.register(r'prompts', AIPromptViewSet, basename='prompts') 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/account', AccountSettingsViewSet, basename='account-settings')
router.register(r'settings/user', UserSettingsViewSet, basename='user-settings') router.register(r'settings/user', UserSettingsViewSet, basename='user-settings')
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-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') router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings')
# Custom URL patterns for integration settings - matching reference plugin structure # Custom URL patterns for integration settings - matching reference plugin structure

View File

@@ -4,6 +4,7 @@ import { HelmetProvider } from "react-helmet-async";
import AppLayout from "./layout/AppLayout"; import AppLayout from "./layout/AppLayout";
import { ScrollToTop } from "./components/common/ScrollToTop"; import { ScrollToTop } from "./components/common/ScrollToTop";
import ProtectedRoute from "./components/auth/ProtectedRoute"; import ProtectedRoute from "./components/auth/ProtectedRoute";
import ModuleGuard from "./components/common/ModuleGuard";
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay"; import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
import LoadingStateMonitor from "./components/common/LoadingStateMonitor"; import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
@@ -133,90 +134,122 @@ export default function App() {
{/* Planner Module */} {/* Planner Module */}
<Route path="/planner" element={ <Route path="/planner" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<PlannerDashboard /> <ModuleGuard module="planner">
<PlannerDashboard />
</ModuleGuard>
</Suspense> </Suspense>
} /> } />
<Route path="/planner/keywords" element={ <Route path="/planner/keywords" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<Keywords /> <ModuleGuard module="planner">
<Keywords />
</ModuleGuard>
</Suspense> </Suspense>
} /> } />
<Route path="/planner/clusters" element={ <Route path="/planner/clusters" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<Clusters /> <ModuleGuard module="planner">
<Clusters />
</ModuleGuard>
</Suspense> </Suspense>
} /> } />
<Route path="/planner/ideas" element={ <Route path="/planner/ideas" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<Ideas /> <ModuleGuard module="planner">
<Ideas />
</ModuleGuard>
</Suspense> </Suspense>
} /> } />
{/* Writer Module */} {/* Writer Module */}
<Route path="/writer" element={ <Route path="/writer" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<WriterDashboard /> <WriterDashboard />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
<Route path="/writer/tasks" element={ <Route path="/writer/tasks" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<Tasks /> <Tasks />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
{/* Writer Content Routes - Order matters: list route must come before detail route */} {/* Writer Content Routes - Order matters: list route must come before detail route */}
<Route path="/writer/content" element={ <Route path="/writer/content" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<Content /> <Content />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
{/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */} {/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */}
<Route path="/writer/content/:id" element={ <Route path="/writer/content/:id" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<ContentView /> <ContentView />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} /> <Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
<Route path="/writer/images" element={ <Route path="/writer/images" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<Images /> <Images />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
<Route path="/writer/published" element={ <Route path="/writer/published" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<Published /> <Published />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
{/* Thinker Module */} {/* Thinker Module */}
<Route path="/thinker" element={ <Route path="/thinker" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<ThinkerDashboard /> <ThinkerDashboard />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
<Route path="/thinker/prompts" element={ <Route path="/thinker/prompts" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<Prompts /> <Prompts />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
<Route path="/thinker/author-profiles" element={ <Route path="/thinker/author-profiles" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<AuthorProfiles /> <AuthorProfiles />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
<Route path="/thinker/profile" element={ <Route path="/thinker/profile" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<ThinkerProfile /> <ThinkerProfile />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
<Route path="/thinker/strategies" element={ <Route path="/thinker/strategies" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<Strategies /> <Strategies />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
<Route path="/thinker/image-testing" element={ <Route path="/thinker/image-testing" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<ImageTesting /> <ImageTesting />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
{/* Billing Module */} {/* Billing Module */}
@@ -256,8 +289,10 @@ export default function App() {
{/* Other Pages */} {/* Other Pages */}
<Route path="/automation" element={ <Route path="/automation" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="automation">
<AutomationDashboard /> <AutomationDashboard />
</Suspense> </ModuleGuard>
</Suspense>
} /> } />
<Route path="/schedules" element={ <Route path="/schedules" element={
<Suspense fallback={null}> <Suspense fallback={null}>

View File

@@ -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 <Navigate to={redirectTo} replace />;
}
return <>{children}</>;
}

View File

@@ -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<string, ModuleConfig> = {
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<string, boolean>): 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<string, boolean>): 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;
}

View File

@@ -24,36 +24,63 @@ const LayoutContent: React.FC = () => {
const [debugEnabled, setDebugEnabled] = useState(false); const [debugEnabled, setDebugEnabled] = useState(false);
const lastUserRefresh = useRef<number>(0); const lastUserRefresh = useRef<number>(0);
// Initialize site store on mount - only once // Initialize site store on mount - only once, but only if authenticated
useEffect(() => { useEffect(() => {
if (!hasLoadedSite.current && !isLoadingSite.current) { // Only load sites if user is authenticated AND has a token
hasLoadedSite.current = true; if (!isAuthenticated) return;
isLoadingSite.current = true;
trackLoading('site-loading', true); // 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();
}
}, 100); // Wait 100ms for persist to write
return;
}
// Add timeout to prevent infinite loading loadSites();
// Match API timeout (30s) + buffer for network delays };
const timeoutId = setTimeout(() => {
if (isLoadingSite.current) { const loadSites = () => {
console.error('AppLayout: Site loading timeout after 35 seconds'); if (!hasLoadedSite.current && !isLoadingSite.current) {
trackLoading('site-loading', false); hasLoadedSite.current = true;
isLoadingSite.current = false; isLoadingSite.current = true;
addError(new Error('Site loading timeout - check network connection'), 'AppLayout.loadActiveSite'); trackLoading('site-loading', true);
}
}, 35000); // 35 seconds to match API timeout (30s) + buffer // Add timeout to prevent infinite loading
// Match API timeout (30s) + buffer for network delays
loadActiveSite() const timeoutId = setTimeout(() => {
.catch((error) => { if (isLoadingSite.current) {
console.error('AppLayout: Error loading active site:', error); console.error('AppLayout: Site loading timeout after 35 seconds');
addError(error, 'AppLayout.loadActiveSite'); trackLoading('site-loading', false);
}) isLoadingSite.current = false;
.finally(() => { addError(new Error('Site loading timeout - check network connection'), 'AppLayout.loadActiveSite');
clearTimeout(timeoutId); }
trackLoading('site-loading', false); }, 35000); // 35 seconds to match API timeout (30s) + buffer
isLoadingSite.current = false;
}); loadActiveSite()
} .catch((error) => {
}, []); // Empty deps - only run once on mount // 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) // Load sectors when active site changes (by ID, not object reference)
useEffect(() => { useEffect(() => {
@@ -114,6 +141,19 @@ const LayoutContent: React.FC = () => {
// Throttle: only refresh if last refresh was more than 30 seconds ago (unless forced) // Throttle: only refresh if last refresh was more than 30 seconds ago (unless forced)
if (!force && now - lastUserRefresh.current < 30000) return; 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 { try {
lastUserRefresh.current = now; lastUserRefresh.current = now;
await refreshUser(); await refreshUser();

View File

@@ -20,6 +20,7 @@ import { useSidebar } from "../context/SidebarContext";
import SidebarWidget from "./SidebarWidget"; import SidebarWidget from "./SidebarWidget";
import { APP_VERSION } from "../config/version"; import { APP_VERSION } from "../config/version";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useSettingsStore } from "../store/settingsStore";
import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator"; import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator";
type NavItem = { type NavItem = {
@@ -37,13 +38,20 @@ type MenuSection = {
const AppSidebar: React.FC = () => { const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar(); const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const location = useLocation(); 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 // Show admin menu only for users in aws-admin account
const isAwsAdminAccount = Boolean( const isAwsAdminAccount = Boolean(
user?.account?.slug === 'aws-admin' || user?.account?.slug === 'aws-admin' ||
user?.role === 'developer' // Also show for developers as fallback user?.role === 'developer' // Also show for developers as fallback
); );
// 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<{ const [openSubmenu, setOpenSubmenu] = useState<{
sectionIndex: number; sectionIndex: number;
@@ -59,78 +67,109 @@ const AppSidebar: React.FC = () => {
[location.pathname] [location.pathname]
); );
// Load module enable settings on mount (only once) - but only if user is authenticated
useEffect(() => {
// 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);
});
}
}, [user, isAuthenticated]); // Only run when user/auth state changes
// Define menu sections with useMemo to prevent recreation on every render // Define menu sections with useMemo to prevent recreation on every render
const menuSections: MenuSection[] = useMemo(() => [ // Filter out disabled modules based on module enable settings
{ const menuSections: MenuSection[] = useMemo(() => {
label: "OVERVIEW", const workflowItems: NavItem[] = [
items: [ {
{ icon: <PlugInIcon />,
icon: <GridIcon />, name: "Setup",
name: "Dashboard", subItems: [
path: "/", { name: "Sites", path: "/settings/sites" },
}, { name: "Keywords Opportunities", path: "/planner/keyword-opportunities" },
{ ],
icon: <DocsIcon />, },
name: "Industry / Sectors", ];
path: "/reference/industries",
}, // Add Planner if enabled
], if (moduleEnabled('planner')) {
}, workflowItems.push({
{ icon: <ListIcon />,
label: "WORKFLOWS", name: "Planner",
items: [ subItems: [
{ { name: "Dashboard", path: "/planner" },
icon: <PlugInIcon />, { name: "Keywords", path: "/planner/keywords" },
name: "Setup", { name: "Clusters", path: "/planner/clusters" },
subItems: [ { name: "Ideas", path: "/planner/ideas" },
{ name: "Sites", path: "/settings/sites" }, ],
{ name: "Keywords Opportunities", path: "/planner/keyword-opportunities" }, });
], }
},
{ // Add Writer if enabled
icon: <ListIcon />, if (moduleEnabled('writer')) {
name: "Planner", workflowItems.push({
subItems: [ icon: <TaskIcon />,
{ name: "Dashboard", path: "/planner" }, name: "Writer",
{ name: "Keywords", path: "/planner/keywords" }, subItems: [
{ name: "Clusters", path: "/planner/clusters" }, { name: "Dashboard", path: "/writer" },
{ name: "Ideas", path: "/planner/ideas" }, { name: "Tasks", path: "/writer/tasks" },
], { name: "Content", path: "/writer/content" },
}, { name: "Images", path: "/writer/images" },
{ { name: "Published", path: "/writer/published" },
icon: <TaskIcon />, ],
name: "Writer", });
subItems: [ }
{ name: "Dashboard", path: "/writer" },
{ name: "Tasks", path: "/writer/tasks" }, // Add Thinker if enabled
{ name: "Content", path: "/writer/content" }, if (moduleEnabled('thinker')) {
{ name: "Images", path: "/writer/images" }, workflowItems.push({
{ name: "Published", path: "/writer/published" }, icon: <BoltIcon />,
], name: "Thinker",
}, subItems: [
{ { name: "Dashboard", path: "/thinker" },
icon: <BoltIcon />, { name: "Prompts", path: "/thinker/prompts" },
name: "Thinker", { name: "Author Profiles", path: "/thinker/author-profiles" },
subItems: [ { name: "Strategies", path: "/thinker/strategies" },
{ name: "Dashboard", path: "/thinker" }, { name: "Image Testing", path: "/thinker/image-testing" },
{ 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: <BoltIcon />, icon: <BoltIcon />,
name: "Automation", name: "Automation",
path: "/automation", path: "/automation",
}, });
{ }
icon: <TimeIcon />,
name: "Schedules", workflowItems.push({
path: "/schedules", icon: <TimeIcon />,
}, name: "Schedules",
], path: "/schedules",
}, });
return [
{
label: "OVERVIEW",
items: [
{
icon: <GridIcon />,
name: "Dashboard",
path: "/",
},
{
icon: <DocsIcon />,
name: "Industry / Sectors",
path: "/reference/industries",
},
],
},
{
label: "WORKFLOWS",
items: workflowItems,
},
{ {
label: "ACCOUNT & SETTINGS", label: "ACCOUNT & SETTINGS",
items: [ items: [
@@ -165,7 +204,8 @@ const AppSidebar: React.FC = () => {
}, },
], ],
}, },
], []); ];
}, [moduleEnabled]);
// Admin section - only shown for users in aws-admin account // Admin section - only shown for users in aws-admin account
const adminSection: MenuSection = useMemo(() => ({ const adminSection: MenuSection = useMemo(() => ({
@@ -266,9 +306,15 @@ const AppSidebar: React.FC = () => {
}); });
if (shouldOpen) { if (shouldOpen) {
setOpenSubmenu({ setOpenSubmenu((prev) => {
sectionIndex, // Only update if different to prevent infinite loops
itemIndex, if (prev?.sectionIndex === sectionIndex && prev?.itemIndex === itemIndex) {
return prev;
}
return {
sectionIndex,
itemIndex,
};
}); });
foundMatch = true; foundMatch = true;
} }
@@ -291,10 +337,16 @@ const AppSidebar: React.FC = () => {
// scrollHeight should work even when height is 0px due to overflow-hidden // scrollHeight should work even when height is 0px due to overflow-hidden
const scrollHeight = element.scrollHeight; const scrollHeight = element.scrollHeight;
if (scrollHeight > 0) { if (scrollHeight > 0) {
setSubMenuHeight((prevHeights) => ({ setSubMenuHeight((prevHeights) => {
...prevHeights, // Only update if height changed to prevent infinite loops
[key]: scrollHeight, if (prevHeights[key] === scrollHeight) {
})); return prevHeights;
}
return {
...prevHeights,
[key]: scrollHeight,
};
});
} }
} }
}, 50); }, 50);

View File

@@ -5,6 +5,19 @@ import { fetchCreditBalance, CreditBalance } from '../../services/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
// Credit costs per operation (Phase 0: Credit-only system)
const CREDIT_COSTS: Record<string, { cost: number | string; description: string }> = {
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() { export default function Credits() {
const toast = useToast(); const toast = useToast();
const [balance, setBalance] = useState<CreditBalance | null>(null); const [balance, setBalance] = useState<CreditBalance | null>(null);
@@ -88,6 +101,35 @@ export default function Credits() {
</Card> </Card>
</div> </div>
)} )}
{/* Credit Costs Reference */}
<div className="mt-8">
<Card className="p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Credit Costs per Operation</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Understanding how credits are consumed for each operation type
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(CREDIT_COSTS).map(([operation, info]) => (
<div key={operation} className="flex items-start justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white capitalize">
{operation.replace(/_/g, ' ')}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{info.description}
</div>
</div>
<div className="ml-4 text-right">
<Badge variant="light" color="primary" className="font-semibold">
{typeof info.cost === 'number' ? `${info.cost} credits` : info.cost}
</Badge>
</div>
</div>
))}
</div>
</Card>
</div>
</div> </div>
); );
} }

View File

@@ -5,6 +5,19 @@ import { fetchCreditUsage, CreditUsageLog, fetchUsageLimits, LimitCard } from '.
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
// Credit costs per operation (Phase 0: Credit-only system)
const CREDIT_COSTS: Record<string, { cost: number | string; description: string }> = {
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() { export default function Usage() {
const toast = useToast(); const toast = useToast();
const [usageLogs, setUsageLogs] = useState<CreditUsageLog[]>([]); const [usageLogs, setUsageLogs] = useState<CreditUsageLog[]>([]);
@@ -33,13 +46,8 @@ export default function Usage() {
try { try {
setLimitsLoading(true); setLimitsLoading(true);
const response = await fetchUsageLimits(); const response = await fetchUsageLimits();
console.log('Usage limits response:', response);
setLimits(response.limits || []); setLimits(response.limits || []);
if (!response.limits || response.limits.length === 0) {
console.warn('No limits data received from API');
}
} catch (error: any) { } catch (error: any) {
console.error('Error loading usage limits:', error);
toast.error(`Failed to load usage limits: ${error.message}`); toast.error(`Failed to load usage limits: ${error.message}`);
setLimits([]); setLimits([]);
} finally { } finally {
@@ -47,120 +55,82 @@ export default function Usage() {
} }
}; };
const groupedLimits = { // Filter limits to show only credits and account management (Phase 0: Credit-only system)
planner: limits.filter(l => l.category === 'planner'), const creditLimits = limits.filter(l => l.category === 'credits');
writer: limits.filter(l => l.category === 'writer'), const accountLimits = limits.filter(l => l.category === 'account');
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,
});
return ( return (
<div className="p-6"> <div className="p-6">
<PageMeta title="Usage" description="Monitor your plan limits and usage statistics" /> <PageMeta title="Usage" description="Monitor your credit usage and account limits" />
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Acoount Limits Usage 12</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Credit Usage & Limits</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Monitor your plan limits and usage statistics</p> <p className="text-gray-600 dark:text-gray-400 mt-1">Monitor your credit usage and account management limits</p>
</div> </div>
{/* Debug Info - Remove in production */} {/* Credit Costs Reference */}
{import.meta.env.DEV && ( <Card className="p-6 mb-6">
<Card className="p-4 mb-4 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800"> <h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Credit Costs per Operation</h2>
<div className="text-xs text-gray-600 dark:text-gray-400"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<strong>Debug:</strong> Loading={limitsLoading ? 'Yes' : 'No'}, Limits={limits.length}, {Object.entries(CREDIT_COSTS).map(([operation, info]) => (
Planner={groupedLimits.planner.length}, Writer={groupedLimits.writer.length}, <div key={operation} className="flex items-start justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
Images={groupedLimits.images.length}, AI={groupedLimits.ai.length}, General={groupedLimits.general.length} <div className="flex-1">
</div> <div className="font-medium text-gray-900 dark:text-white capitalize">
</Card> {operation.replace(/_/g, ' ')}
)} </div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{info.description}
</div>
</div>
<div className="ml-4 text-right">
<Badge variant="light" color="primary" className="font-semibold">
{typeof info.cost === 'number' ? `${info.cost} credits` : info.cost}
</Badge>
</div>
</div>
))}
</div>
</Card>
{/* Limit Cards by Category */} {/* Credit Limits */}
{limitsLoading ? ( {limitsLoading ? (
<Card className="p-6 mb-8"> <Card className="p-6 mb-8">
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<div className="text-gray-500">Loading limits...</div> <div className="text-gray-500">Loading limits...</div>
</div> </div>
</Card> </Card>
) : limits.length === 0 ? (
<Card className="p-6 mb-8">
<div className="text-center text-gray-500 dark:text-gray-400">
<p className="mb-2 font-medium">No usage limits data available.</p>
<p className="text-sm">The API endpoint may not be responding or your account may not have a plan configured.</p>
<p className="text-xs mt-2 text-gray-400">Check browser console for errors. Endpoint: /v1/billing/credits/usage/limits/</p>
</div>
</Card>
) : ( ) : (
<div className="space-y-6 mb-8"> <div className="space-y-6 mb-8">
{/* Planner Limits */} {/* Credit Usage Limits */}
{groupedLimits.planner.length > 0 && ( {creditLimits.length > 0 && (
<div> <div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Planner Limits</h2> <h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Credit Usage</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedLimits.planner.map((limit, idx) => ( {creditLimits.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} /> <LimitCardComponent key={idx} limit={limit} />
))} ))}
</div> </div>
</div> </div>
)} )}
{/* Writer Limits */} {/* Account Management Limits */}
{groupedLimits.writer.length > 0 && ( {accountLimits.length > 0 && (
<div> <div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Writer Limits</h2> <h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Account Management</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedLimits.writer.map((limit, idx) => ( {accountLimits.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} /> <LimitCardComponent key={idx} limit={limit} />
))} ))}
</div> </div>
</div> </div>
)} )}
{/* Image Limits */} {creditLimits.length === 0 && accountLimits.length === 0 && (
{groupedLimits.images.length > 0 && ( <Card className="p-6">
<div> <div className="text-center text-gray-500 dark:text-gray-400">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Image Generation Limits</h2> <p className="mb-2 font-medium">No limits data available.</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <p className="text-sm">Your account may not have a plan configured.</p>
{groupedLimits.images.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} />
))}
</div> </div>
</div> </Card>
)}
{/* AI Credits */}
{groupedLimits.ai.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">AI Credits</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedLimits.ai.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} />
))}
</div>
</div>
)}
{/* General Limits */}
{groupedLimits.general.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">General Limits</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedLimits.general.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} />
))}
</div>
</div>
)} )}
</div> </div>
)} )}
@@ -219,22 +189,20 @@ export default function Usage() {
function LimitCardComponent({ limit }: { limit: LimitCard }) { function LimitCardComponent({ limit }: { limit: LimitCard }) {
const getCategoryColor = (category: string) => { const getCategoryColor = (category: string) => {
switch (category) { switch (category) {
case 'planner': return 'blue'; case 'credits': return 'primary';
case 'writer': return 'green'; case 'account': return 'gray';
case 'images': return 'purple';
case 'ai': return 'orange';
case 'general': return 'gray';
default: 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 >= 90) return 'danger';
if (percentage >= 75) return 'warning'; if (percentage >= 75) return 'warning';
return 'success'; 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 status = getUsageStatus(percentage);
const color = getCategoryColor(limit.category); const color = getCategoryColor(limit.category);
@@ -242,12 +210,16 @@ function LimitCardComponent({ limit }: { limit: LimitCard }) {
? 'bg-red-500' ? 'bg-red-500'
: status === 'warning' : status === 'warning'
? 'bg-yellow-500' ? 'bg-yellow-500'
: status === 'info'
? 'bg-blue-500'
: 'bg-green-500'; : 'bg-green-500';
const statusTextColor = status === 'danger' const statusTextColor = status === 'danger'
? 'text-red-600 dark:text-red-400' ? 'text-red-600 dark:text-red-400'
: status === 'warning' : status === 'warning'
? 'text-yellow-600 dark:text-yellow-400' ? 'text-yellow-600 dark:text-yellow-400'
: status === 'info'
? 'text-blue-600 dark:text-blue-400'
: 'text-green-600 dark:text-green-400'; : 'text-green-600 dark:text-green-400';
return ( return (
@@ -258,26 +230,44 @@ function LimitCardComponent({ limit }: { limit: LimitCard }) {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-gray-900 dark:text-white">{limit.used.toLocaleString()}</span> {limit.limit !== null && limit.limit !== undefined ? (
<span className="text-sm text-gray-500 dark:text-gray-400">/ {limit.limit.toLocaleString()}</span> <>
<span className="text-xs text-gray-400 dark:text-gray-500">{limit.unit}</span> <span className="text-2xl font-bold text-gray-900 dark:text-white">{limit.used.toLocaleString()}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">/ {limit.limit.toLocaleString()}</span>
</>
) : (
<span className="text-2xl font-bold text-gray-900 dark:text-white">
{limit.available !== null && limit.available !== undefined ? limit.available.toLocaleString() : limit.used.toLocaleString()}
</span>
)}
{limit.unit && (
<span className="text-xs text-gray-400 dark:text-gray-500">{limit.unit}</span>
)}
</div> </div>
<div className="mt-2"> {percentage !== null && (
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"> <div className="mt-2">
<div <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
className={`h-2 rounded-full ${statusColorClass}`} <div
style={{ width: `${percentage}%` }} className={`h-2 rounded-full ${statusColorClass}`}
/> style={{ width: `${percentage}%` }}
/>
</div>
</div> </div>
</div> )}
</div> </div>
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className={statusTextColor}> {limit.available !== null && limit.available !== undefined ? (
{limit.available.toLocaleString()} available <span className={statusTextColor}>
</span> {limit.available.toLocaleString()} available
<span className="text-gray-500 dark:text-gray-400"> </span>
{percentage.toFixed(1)}% used ) : (
</span> <span className="text-gray-500 dark:text-gray-400">Current value</span>
)}
{percentage !== null && (
<span className="text-gray-500 dark:text-gray-400">
{percentage.toFixed(1)}% used
</span>
)}
</div> </div>
</Card> </Card>
); );

View File

@@ -1,36 +1,48 @@
import { useState, useEffect } from 'react'; import { useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta'; import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer'; 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 { Card } from '../../components/ui/card';
import Switch from '../../components/form/switch/Switch';
export default function ModuleSettings() { export default function ModuleSettings() {
const toast = useToast(); const toast = useToast();
const [settings, setSettings] = useState<any[]>([]); const {
const [loading, setLoading] = useState(true); moduleEnableSettings,
loadModuleEnableSettings,
updateModuleEnableSettings,
loading,
} = useSettingsStore();
useEffect(() => { useEffect(() => {
loadSettings(); loadModuleEnableSettings();
}, []); }, [loadModuleEnableSettings]);
const loadSettings = async () => { const handleToggle = async (moduleName: string, enabled: boolean) => {
try { try {
setLoading(true); const enabledKey = `${moduleName}_enabled` as keyof typeof moduleEnableSettings;
const response = await fetchAPI('/v1/system/settings/modules/'); await updateModuleEnableSettings({
setSettings(response.results || []); [enabledKey]: enabled,
} as any);
toast.success(`${MODULES[moduleName]?.name || moduleName} ${enabled ? 'enabled' : 'disabled'}`);
} catch (error: any) { } catch (error: any) {
toast.error(`Failed to load module settings: ${error.message}`); toast.error(`Failed to update module: ${error.message}`);
} finally {
setLoading(false);
} }
}; };
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 ( return (
<div className="p-6"> <div className="p-6">
<PageMeta title="Module Settings" /> <PageMeta title="Module Settings" />
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Module Settings</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Module Settings</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Module-specific configuration</p> <p className="text-gray-600 dark:text-gray-400 mt-1">Enable or disable modules for your account</p>
</div> </div>
{loading ? ( {loading ? (
@@ -39,7 +51,38 @@ export default function ModuleSettings() {
</div> </div>
) : ( ) : (
<Card className="p-6"> <Card className="p-6">
<p className="text-gray-600 dark:text-gray-400">Module settings management interface coming soon.</p> <div className="space-y-6">
{Object.entries(MODULES).map(([key, module]) => (
<div
key={key}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-2xl">{module.icon}</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{module.name}
</h3>
{module.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{module.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 dark:text-gray-400">
{getModuleEnabled(key) ? 'Enabled' : 'Disabled'}
</span>
<Switch
label=""
checked={getModuleEnabled(key)}
onChange={(enabled) => handleToggle(key, enabled)}
/>
</div>
</div>
))}
</div>
</Card> </Card>
)} )}
</div> </div>

View File

@@ -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 => { const getAuthToken = (): string | null => {
try { 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'); const authStorage = localStorage.getItem('auth-storage');
if (authStorage) { if (authStorage) {
const parsed = JSON.parse(authStorage); const parsed = JSON.parse(authStorage);
@@ -92,9 +99,16 @@ const getAuthToken = (): string | null => {
return 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 => { const getRefreshToken = (): string | null => {
try { 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'); const authStorage = localStorage.getItem('auth-storage');
if (authStorage) { if (authStorage) {
const parsed = JSON.parse(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') || if (errorData?.detail?.includes('Authentication credentials') ||
errorData?.message?.includes('Authentication credentials') || errorData?.message?.includes('Authentication credentials') ||
errorData?.error?.includes('Authentication credentials')) { errorData?.error?.includes('Authentication credentials')) {
// Token is invalid - clear auth state and force re-login // Only logout if we actually have a token stored (means it's invalid)
const { logout } = useAuthStore.getState(); // If no token, it might be a race condition after login - don't logout
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 // Don't throw here - let the error handling below show the error
} }
} catch (e) { } catch (e) {
@@ -1474,6 +1493,20 @@ export async function deleteAccountSetting(key: string): Promise<void> {
} }
// Module Settings // 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 { export interface ModuleSetting {
id: number; id: number;
module_name: string; module_name: string;
@@ -1498,6 +1531,19 @@ export async function createModuleSetting(data: { module_name: string; key: stri
}); });
} }
export async function fetchModuleEnableSettings(): Promise<ModuleEnableSettings> {
const response = await fetchAPI('/v1/system/settings/modules/enable/');
return response;
}
export async function updateModuleEnableSettings(data: Partial<ModuleEnableSettings>): Promise<ModuleEnableSettings> {
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<string, any>; is_active: boolean }>): Promise<ModuleSetting> { export async function updateModuleSetting(moduleName: string, key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<ModuleSetting> {
return fetchAPI(`/v1/system/settings/modules/${key}/?module_name=${moduleName}`, { return fetchAPI(`/v1/system/settings/modules/${key}/?module_name=${moduleName}`, {
method: 'PUT', method: 'PUT',

View File

@@ -13,13 +13,17 @@ import {
fetchModuleSettings, fetchModuleSettings,
createModuleSetting, createModuleSetting,
updateModuleSetting, updateModuleSetting,
fetchModuleEnableSettings,
updateModuleEnableSettings,
AccountSetting, AccountSetting,
ModuleSetting, ModuleSetting,
ModuleEnableSettings,
} from '../services/api'; } from '../services/api';
interface SettingsState { interface SettingsState {
accountSettings: Record<string, AccountSetting>; accountSettings: Record<string, AccountSetting>;
moduleSettings: Record<string, Record<string, ModuleSetting>>; moduleSettings: Record<string, Record<string, ModuleSetting>>;
moduleEnableSettings: ModuleEnableSettings | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@@ -29,6 +33,9 @@ interface SettingsState {
updateAccountSetting: (key: string, value: any) => Promise<void>; updateAccountSetting: (key: string, value: any) => Promise<void>;
loadModuleSettings: (moduleName: string) => Promise<void>; loadModuleSettings: (moduleName: string) => Promise<void>;
updateModuleSetting: (moduleName: string, key: string, value: any) => Promise<void>; updateModuleSetting: (moduleName: string, key: string, value: any) => Promise<void>;
loadModuleEnableSettings: () => Promise<void>;
updateModuleEnableSettings: (data: Partial<ModuleEnableSettings>) => Promise<void>;
isModuleEnabled: (moduleName: string) => boolean;
reset: () => void; reset: () => void;
} }
@@ -37,6 +44,7 @@ export const useSettingsStore = create<SettingsState>()(
(set, get) => ({ (set, get) => ({
accountSettings: {}, accountSettings: {},
moduleSettings: {}, moduleSettings: {},
moduleEnableSettings: null,
loading: false, loading: false,
error: null, error: null,
@@ -135,10 +143,40 @@ export const useSettingsStore = create<SettingsState>()(
} }
}, },
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<ModuleEnableSettings>) => {
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: () => { reset: () => {
set({ set({
accountSettings: {}, accountSettings: {},
moduleSettings: {}, moduleSettings: {},
moduleEnableSettings: null,
loading: false, loading: false,
error: null, error: null,
}); });
@@ -149,6 +187,7 @@ export const useSettingsStore = create<SettingsState>()(
partialize: (state) => ({ partialize: (state) => ({
accountSettings: state.accountSettings, accountSettings: state.accountSettings,
moduleSettings: state.moduleSettings, moduleSettings: state.moduleSettings,
moduleEnableSettings: state.moduleEnableSettings,
}), }),
} }
) )