diff --git a/backend/igny8_core/api/account_views.py b/backend/igny8_core/api/account_views.py index 404b7dec..886b6af3 100644 --- a/backend/igny8_core/api/account_views.py +++ b/backend/igny8_core/api/account_views.py @@ -11,12 +11,66 @@ from django.db.models import Q, Count, Sum from django.utils import timezone from datetime import timedelta from decimal import Decimal +import logging +import secrets from drf_spectacular.utils import extend_schema, extend_schema_view -from igny8_core.auth.models import Account +from igny8_core.auth.models import Account, PasswordResetToken + +COUNTRY_TIMEZONE_MAP = { + 'US': 'America/New_York', + 'GB': 'Europe/London', + 'CA': 'America/Toronto', + 'AU': 'Australia/Sydney', + 'IN': 'Asia/Kolkata', + 'PK': 'Asia/Karachi', + 'DE': 'Europe/Berlin', + 'FR': 'Europe/Paris', + 'ES': 'Europe/Madrid', + 'IT': 'Europe/Rome', + 'NL': 'Europe/Amsterdam', + 'SE': 'Europe/Stockholm', + 'NO': 'Europe/Oslo', + 'DK': 'Europe/Copenhagen', + 'FI': 'Europe/Helsinki', + 'BE': 'Europe/Brussels', + 'AT': 'Europe/Vienna', + 'CH': 'Europe/Zurich', + 'IE': 'Europe/Dublin', + 'NZ': 'Pacific/Auckland', + 'SG': 'Asia/Singapore', + 'AE': 'Asia/Dubai', + 'SA': 'Asia/Riyadh', + 'ZA': 'Africa/Johannesburg', + 'BR': 'America/Sao_Paulo', + 'MX': 'America/Mexico_City', + 'AR': 'America/Argentina/Buenos_Aires', + 'CL': 'America/Santiago', + 'CO': 'America/Bogota', + 'JP': 'Asia/Tokyo', + 'KR': 'Asia/Seoul', + 'CN': 'Asia/Shanghai', + 'TH': 'Asia/Bangkok', + 'MY': 'Asia/Kuala_Lumpur', + 'ID': 'Asia/Jakarta', + 'PH': 'Asia/Manila', + 'VN': 'Asia/Ho_Chi_Minh', + 'BD': 'Asia/Dhaka', + 'LK': 'Asia/Colombo', + 'EG': 'Africa/Cairo', + 'NG': 'Africa/Lagos', + 'KE': 'Africa/Nairobi', + 'GH': 'Africa/Accra', +} + +def _timezone_for_country(country_code: str | None) -> str: + if not country_code: + return 'UTC' + return COUNTRY_TIMEZONE_MAP.get(country_code.upper(), 'UTC') from igny8_core.business.billing.models import CreditTransaction User = get_user_model() +logger = logging.getLogger(__name__) @extend_schema_view( @@ -43,6 +97,9 @@ class AccountSettingsViewSet(viewsets.ViewSet): 'billing_country': account.billing_country or '', 'tax_id': account.tax_id or '', 'billing_email': account.billing_email or '', + 'account_timezone': account.account_timezone or 'UTC', + 'timezone_mode': account.timezone_mode or 'country', + 'timezone_offset': account.timezone_offset or '', 'credits': account.credits, 'created_at': account.created_at.isoformat(), 'updated_at': account.updated_at.isoformat(), @@ -56,12 +113,19 @@ class AccountSettingsViewSet(viewsets.ViewSet): allowed_fields = [ 'name', 'billing_address_line1', 'billing_address_line2', 'billing_city', 'billing_state', 'billing_postal_code', - 'billing_country', 'tax_id', 'billing_email' + 'billing_country', 'tax_id', 'billing_email', + 'account_timezone', 'timezone_mode', 'timezone_offset' ] for field in allowed_fields: if field in request.data: setattr(account, field, request.data[field]) + + # Derive timezone from country unless manual mode is selected + if getattr(account, 'timezone_mode', 'country') != 'manual': + country_code = account.billing_country + account.account_timezone = _timezone_for_country(country_code) + account.timezone_offset = '' account.save() @@ -79,6 +143,9 @@ class AccountSettingsViewSet(viewsets.ViewSet): 'billing_country': account.billing_country, 'tax_id': account.tax_id, 'billing_email': account.billing_email, + 'account_timezone': account.account_timezone, + 'timezone_mode': account.timezone_mode, + 'timezone_offset': account.timezone_offset, } }) @@ -142,13 +209,37 @@ class TeamManagementViewSet(viewsets.ViewSet): status=status.HTTP_400_BAD_REQUEST ) - # Create user (simplified - in production, send invitation email) + # Generate username from email if not provided + base_username = email.split('@')[0] + username = base_username + counter = 1 + while User.objects.filter(username=username).exists(): + username = f"{base_username}{counter}" + counter += 1 + + # Create user and send invitation email user = User.objects.create_user( + username=username, email=email, first_name=request.data.get('first_name', ''), last_name=request.data.get('last_name', ''), account=account ) + + # Create password reset token for invite + token = secrets.token_urlsafe(32) + expires_at = timezone.now() + timedelta(hours=24) + PasswordResetToken.objects.create( + user=user, + token=token, + expires_at=expires_at + ) + + try: + from igny8_core.business.billing.services.email_service import send_team_invite_email + send_team_invite_email(user, request.user, account, token) + except Exception as e: + logger.error(f"Failed to send team invite email: {e}") return Response({ 'message': 'Team member invited successfully', diff --git a/backend/igny8_core/auth/migrations/0032_add_account_timezone_fields.py b/backend/igny8_core/auth/migrations/0032_add_account_timezone_fields.py new file mode 100644 index 00000000..62ad63f8 --- /dev/null +++ b/backend/igny8_core/auth/migrations/0032_add_account_timezone_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_auth', '0031_drop_all_blueprint_tables'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='account_timezone', + field=models.CharField(default='UTC', help_text='IANA timezone name', max_length=64), + ), + migrations.AddField( + model_name='account', + name='timezone_mode', + field=models.CharField(choices=[('country', 'Country'), ('manual', 'Manual')], default='country', help_text='Timezone selection mode', max_length=20), + ), + migrations.AddField( + model_name='account', + name='timezone_offset', + field=models.CharField(blank=True, default='', help_text='Optional UTC offset label', max_length=10), + ), + ] diff --git a/backend/igny8_core/auth/migrations/0033_add_account_timezone_history_fields.py b/backend/igny8_core/auth/migrations/0033_add_account_timezone_history_fields.py new file mode 100644 index 00000000..5024fa75 --- /dev/null +++ b/backend/igny8_core/auth/migrations/0033_add_account_timezone_history_fields.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.9 on 2026-01-19 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_auth', '0032_add_account_timezone_fields'), + ] + + operations = [ + migrations.AddField( + model_name='historicalaccount', + name='account_timezone', + field=models.CharField(default='UTC', max_length=64, help_text='IANA timezone name'), + ), + migrations.AddField( + model_name='historicalaccount', + name='timezone_mode', + field=models.CharField(choices=[('country', 'Country'), ('manual', 'Manual')], default='country', max_length=20, help_text='Timezone selection mode'), + ), + migrations.AddField( + model_name='historicalaccount', + name='timezone_offset', + field=models.CharField(blank=True, default='', max_length=10, help_text='Optional UTC offset label'), + ), + ] diff --git a/backend/igny8_core/auth/migrations/0034_add_user_phone.py b/backend/igny8_core/auth/migrations/0034_add_user_phone.py new file mode 100644 index 00000000..14d9d58d --- /dev/null +++ b/backend/igny8_core/auth/migrations/0034_add_user_phone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2026-01-19 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_auth', '0033_add_account_timezone_history_fields'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='phone', + field=models.CharField(blank=True, default='', max_length=30), + ), + ] diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index 01f0b977..221ab791 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -106,6 +106,16 @@ class Account(SoftDeletableModel): billing_postal_code = models.CharField(max_length=20, blank=True) billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code") tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number") + + # Account timezone (single source of truth for all users/sites) + account_timezone = models.CharField(max_length=64, default='UTC', help_text="IANA timezone name") + timezone_mode = models.CharField( + max_length=20, + choices=[('country', 'Country'), ('manual', 'Manual')], + default='country', + help_text="Timezone selection mode" + ) + timezone_offset = models.CharField(max_length=10, blank=True, default='', help_text="Optional UTC offset label") # Monthly usage tracking (reset on billing cycle) usage_ahrefs_queries = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Ahrefs queries used this month") @@ -922,6 +932,7 @@ class User(AbstractUser): account = models.ForeignKey('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='users', null=True, blank=True, db_column='tenant_id') role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer') email = models.EmailField(_('email address'), unique=True) + phone = models.CharField(max_length=30, blank=True, default='') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index fd92d4da..58c2a679 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -53,7 +53,9 @@ class AccountSerializer(serializers.ModelSerializer): fields = [ 'id', 'name', 'slug', 'owner', 'plan', 'plan_id', 'credits', 'status', 'payment_method', - 'subscription', 'billing_country', 'created_at' + 'subscription', 'billing_country', + 'account_timezone', 'timezone_mode', 'timezone_offset', + 'created_at' ] read_only_fields = ['owner', 'created_at'] @@ -270,7 +272,18 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['id', 'username', 'email', 'role', 'account', 'accessible_sites', 'created_at'] + fields = [ + 'id', + 'username', + 'email', + 'phone', + 'first_name', + 'last_name', + 'role', + 'account', + 'accessible_sites', + 'created_at', + ] read_only_fields = ['created_at'] def get_accessible_sites(self, obj): diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index dfe1e158..92839a7b 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -255,6 +255,25 @@ class UsersViewSet(AccountModelViewSet): serializer = UserSerializer(user) return success_response(data={'user': serializer.data}, request=request) + @action(detail=False, methods=['get', 'patch'], permission_classes=[IsAuthenticatedAndActive]) + def me(self, request): + """Get or update the current user profile.""" + user = request.user + + if request.method == 'PATCH': + serializer = UserSerializer(user, data=request.data, partial=True) + if not serializer.is_valid(): + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + serializer.save() + + serializer = UserSerializer(user) + return success_response(data={'user': serializer.data}, request=request) + # ============================================================================ # 3. ACCOUNTS - Register each unique organization/user space diff --git a/backend/igny8_core/business/automation/views.py b/backend/igny8_core/business/automation/views.py index 98931bde..56f32652 100644 --- a/backend/igny8_core/business/automation/views.py +++ b/backend/igny8_core/business/automation/views.py @@ -1944,29 +1944,39 @@ class AutomationViewSet(viewsets.ViewSet): def server_time(self, request): """ GET /api/v1/automation/server_time/ - Get current server time (UTC) used for all automation scheduling. + Get current time in the account timezone used for automation scheduling. Returns: - - server_time: Current UTC timestamp (ISO 8601 format) - - server_time_formatted: Human-readable UTC time - - timezone: Server timezone setting (always UTC) + - server_time: Current timestamp (ISO 8601 format, account timezone) + - server_time_formatted: Human-readable time (account timezone) + - timezone: Account timezone setting - celery_timezone: Celery task timezone setting - use_tz: Whether Django is timezone-aware - Note: All automation schedules (scheduled_time) are in UTC. - When user sets "02:00", the automation runs at 02:00 UTC. + Note: Automation schedules are shown in the account timezone. """ from django.conf import settings - + from zoneinfo import ZoneInfo + now = timezone.now() + account = getattr(request.user, 'account', None) + account_timezone = getattr(account, 'account_timezone', None) or 'UTC' + + try: + tzinfo = ZoneInfo(account_timezone) + except Exception: + tzinfo = timezone.utc + account_timezone = 'UTC' + + local_now = now.astimezone(tzinfo) return Response({ - 'server_time': now.isoformat(), - 'server_time_formatted': now.strftime('%H:%M'), - 'server_time_date': now.strftime('%Y-%m-%d'), - 'server_time_time': now.strftime('%H:%M:%S'), - 'timezone': settings.TIME_ZONE, + 'server_time': local_now.isoformat(), + 'server_time_formatted': local_now.strftime('%H:%M'), + 'server_time_date': local_now.strftime('%Y-%m-%d'), + 'server_time_time': local_now.strftime('%H:%M:%S'), + 'timezone': account_timezone, 'celery_timezone': getattr(settings, 'CELERY_TIMEZONE', settings.TIME_ZONE), 'use_tz': settings.USE_TZ, - 'note': 'All automation schedules are in UTC. When you set "02:00", the automation runs at 02:00 UTC.' + 'note': 'Automation schedules are shown in the account timezone.' }) \ No newline at end of file diff --git a/backend/igny8_core/business/billing/services/email_service.py b/backend/igny8_core/business/billing/services/email_service.py index f3e9e001..2f3ec419 100644 --- a/backend/igny8_core/business/billing/services/email_service.py +++ b/backend/igny8_core/business/billing/services/email_service.py @@ -1271,6 +1271,31 @@ def send_password_reset_email(user, reset_token): ) +def send_team_invite_email(invited_user, inviter, account, reset_token): + """Send team invitation email with password setup link""" + service = get_email_service() + frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000') + + inviter_name = inviter.first_name or inviter.email + invited_name = invited_user.first_name or invited_user.email + + context = { + 'inviter_name': inviter_name, + 'invited_name': invited_name, + 'account_name': account.name, + 'reset_url': f'{frontend_url}/reset-password?token={reset_token}', + 'frontend_url': frontend_url, + } + + return service.send_transactional( + to=invited_user.email, + subject=f"You're invited to join {account.name} on IGNY8", + template='emails/team_invite.html', + context=context, + tags=['auth', 'team-invite'], + ) + + def send_email_verification(user, verification_token): """Send email verification link""" service = get_email_service() diff --git a/backend/igny8_core/modules/system/migrations/0026_add_team_invite_email_template.py b/backend/igny8_core/modules/system/migrations/0026_add_team_invite_email_template.py new file mode 100644 index 00000000..6444aaf4 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0026_add_team_invite_email_template.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.9 on 2026-01-19 00:00 + +from django.db import migrations + + +def add_team_invite_template(apps, schema_editor): + EmailTemplate = apps.get_model('system', 'EmailTemplate') + + EmailTemplate.objects.get_or_create( + template_name='team_invite', + defaults={ + 'template_path': 'emails/team_invite.html', + 'display_name': 'Team Invitation', + 'description': 'Sent when a team member is invited to join an account', + 'template_type': 'auth', + 'default_subject': "You're invited to join IGNY8", + 'required_context': ['inviter_name', 'invited_name', 'account_name', 'reset_url', 'frontend_url'], + 'sample_context': { + 'inviter_name': 'Alex Johnson', + 'invited_name': 'Jamie Lee', + 'account_name': 'Acme Co', + 'reset_url': 'https://app.igny8.com/reset-password?token=example', + 'frontend_url': 'https://app.igny8.com', + }, + 'is_active': True, + } + ) + + +def remove_team_invite_template(apps, schema_editor): + EmailTemplate = apps.get_model('system', 'EmailTemplate') + EmailTemplate.objects.filter(template_name='team_invite').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0025_delete_accountintegrationoverride'), + ] + + operations = [ + migrations.RunPython(add_team_invite_template, remove_team_invite_template), + ] diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 9c082731..374d24d3 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -1595,8 +1595,9 @@ class ContentViewSet(SiteSectorModelViewSet): } """ from django.utils import timezone - from datetime import timedelta - from igny8_core.business.integration.models import Site, SitePublishingSettings + from igny8_core.auth.models import Site + from igny8_core.business.integration.models import PublishingSettings + from igny8_core.tasks.publishing_scheduler import _calculate_available_slots import logging logger = logging.getLogger(__name__) @@ -1621,7 +1622,7 @@ class ContentViewSet(SiteSectorModelViewSet): # Get site and publishing settings try: site = Site.objects.get(id=site_id) - pub_settings = SitePublishingSettings.objects.filter(site=site).first() + pub_settings, _ = PublishingSettings.get_or_create_for_site(site) except Site.DoesNotExist: return error_response( error=f'Site {site_id} not found', @@ -1629,41 +1630,25 @@ class ContentViewSet(SiteSectorModelViewSet): request=request ) - # Default settings if none exist - base_time_str = '09:00 AM' - stagger_interval = 15 # minutes - timezone_str = 'America/New_York' - - if pub_settings: - base_time_str = pub_settings.auto_publish_time or base_time_str - stagger_interval = pub_settings.stagger_interval_minutes or stagger_interval - timezone_str = pub_settings.timezone or timezone_str - # Get content items content_qs = self.get_queryset().filter(id__in=content_ids) - # Generate schedule preview + # Generate schedule preview using the same slot logic as automation schedule_preview = [] - now = timezone.now() - - # Parse base time (format: "09:00 AM" or "14:30") - try: - from datetime import datetime - if 'AM' in base_time_str or 'PM' in base_time_str: - time_obj = datetime.strptime(base_time_str, '%I:%M %p').time() - else: - time_obj = datetime.strptime(base_time_str, '%H:%M').time() - except ValueError: - time_obj = datetime.strptime('09:00', '%H:%M').time() - - # Start from tomorrow at base time - start_date = now.replace(hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0) - if start_date <= now: - start_date += timedelta(days=1) - + available_slots = _calculate_available_slots(pub_settings, site) + + if not available_slots: + return error_response( + error='No available publishing slots based on site defaults', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + # Create schedule for each content item for index, content in enumerate(content_qs): - scheduled_at = start_date + timedelta(minutes=stagger_interval * index) + if index >= len(available_slots): + break + scheduled_at = available_slots[index] schedule_preview.append({ 'content_id': content.id, 'title': content.title, @@ -1671,15 +1656,25 @@ class ContentViewSet(SiteSectorModelViewSet): }) logger.info(f"[bulk_schedule_preview] Generated preview for {len(schedule_preview)} items") + + # Base time for display should match first scheduled slot + display_base_time = '09:00' + if schedule_preview: + try: + from datetime import datetime + first_dt = datetime.fromisoformat(schedule_preview[0]['scheduled_at']) + display_base_time = first_dt.strftime('%H:%M') + except Exception: + display_base_time = '09:00' return success_response( data={ 'scheduled_count': len(schedule_preview), 'schedule_preview': schedule_preview, 'site_settings': { - 'base_time': base_time_str, - 'stagger_interval': stagger_interval, - 'timezone': timezone_str, + 'base_time': display_base_time, + 'stagger_interval': pub_settings.stagger_interval_minutes, + 'timezone': site.account.account_timezone if hasattr(site, 'account') else 'UTC', }, }, message=f'Preview generated for {len(schedule_preview)} items', @@ -1699,8 +1694,9 @@ class ContentViewSet(SiteSectorModelViewSet): } """ from django.utils import timezone - from datetime import timedelta - from igny8_core.business.integration.models import Site, SitePublishingSettings + from igny8_core.auth.models import Site + from igny8_core.business.integration.models import PublishingSettings + from igny8_core.tasks.publishing_scheduler import _calculate_available_slots import logging logger = logging.getLogger(__name__) @@ -1726,7 +1722,7 @@ class ContentViewSet(SiteSectorModelViewSet): # Get site and publishing settings try: site = Site.objects.get(id=site_id) - pub_settings = SitePublishingSettings.objects.filter(site=site).first() + pub_settings, _ = PublishingSettings.get_or_create_for_site(site) except Site.DoesNotExist: return error_response( error=f'Site {site_id} not found', @@ -1734,39 +1730,28 @@ class ContentViewSet(SiteSectorModelViewSet): request=request ) - # Default settings if none exist - base_time_str = '09:00 AM' - stagger_interval = 15 # minutes - - if pub_settings and use_site_defaults: - base_time_str = pub_settings.auto_publish_time or base_time_str - stagger_interval = pub_settings.stagger_interval_minutes or stagger_interval - # Get content items content_qs = self.get_queryset().filter(id__in=content_ids) - # Generate schedule and apply + # Generate schedule and apply using the same slot logic as automation + available_slots = _calculate_available_slots(pub_settings, site) if use_site_defaults else [] + + if use_site_defaults and not available_slots: + return error_response( + error='No available publishing slots based on site defaults', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + now = timezone.now() - - # Parse base time - try: - from datetime import datetime - if 'AM' in base_time_str or 'PM' in base_time_str: - time_obj = datetime.strptime(base_time_str, '%I:%M %p').time() - else: - time_obj = datetime.strptime(base_time_str, '%H:%M').time() - except ValueError: - time_obj = datetime.strptime('09:00', '%H:%M').time() - - # Start from tomorrow at base time - start_date = now.replace(hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0) - if start_date <= now: - start_date += timedelta(days=1) - - # Schedule each content item scheduled_count = 0 for index, content in enumerate(content_qs): - scheduled_at = start_date + timedelta(minutes=stagger_interval * index) + if use_site_defaults: + if index >= len(available_slots): + break + scheduled_at = available_slots[index] + else: + scheduled_at = now content.site_status = 'scheduled' content.scheduled_publish_at = scheduled_at content.site_status_updated_at = now diff --git a/backend/igny8_core/tasks/publishing_scheduler.py b/backend/igny8_core/tasks/publishing_scheduler.py index fffa6a5a..71af1380 100644 --- a/backend/igny8_core/tasks/publishing_scheduler.py +++ b/backend/igny8_core/tasks/publishing_scheduler.py @@ -155,55 +155,27 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') -> allowed_days = [day_map.get(d.lower(), -1) for d in publish_days] allowed_days = [d for d in allowed_days if d >= 0] - # Calculate limits - daily_limit = settings.daily_publish_limit - weekly_limit = settings.weekly_publish_limit - monthly_limit = settings.monthly_publish_limit + # Calculate limits from configured publish days/slots + daily_limit = settings.daily_capacity + weekly_limit = settings.weekly_capacity + monthly_limit = settings.monthly_capacity queue_limit = getattr(settings, 'queue_limit', 100) or 100 - # Count existing scheduled/published content - today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - week_start = today_start - timedelta(days=now.weekday()) - month_start = today_start.replace(day=1) - - daily_count = Content.objects.filter( - site=site, - site_status__in=['scheduled', 'publishing', 'published'], - scheduled_publish_at__gte=today_start - ).count() - - weekly_count = Content.objects.filter( - site=site, - site_status__in=['scheduled', 'publishing', 'published'], - scheduled_publish_at__gte=week_start - ).count() - - monthly_count = Content.objects.filter( - site=site, - site_status__in=['scheduled', 'publishing', 'published'], - scheduled_publish_at__gte=month_start - ).count() - # Route to appropriate slot generator - if settings.scheduling_mode == 'stagger': - return _generate_stagger_slots( - settings, site, now, allowed_days, - daily_limit, weekly_limit, monthly_limit, queue_limit, - daily_count, weekly_count, monthly_count - ) - else: - # Default to time_slots mode - return _generate_time_slot_slots( - settings, site, now, allowed_days, - daily_limit, weekly_limit, monthly_limit, queue_limit, - daily_count, weekly_count, monthly_count - ) + # Always use time_slots mode for scheduling + account_timezone = getattr(site.account, 'account_timezone', 'UTC') if hasattr(site, 'account') else 'UTC' + + return _generate_time_slot_slots( + settings, site, now, allowed_days, + daily_limit, weekly_limit, monthly_limit, queue_limit, + account_timezone + ) def _generate_time_slot_slots( settings, site, now, allowed_days, daily_limit, weekly_limit, monthly_limit, queue_limit, - daily_count, weekly_count, monthly_count + account_timezone: str ) -> list: """Generate slots based on specific time slots (original mode).""" from igny8_core.business.content.models import Content @@ -226,41 +198,87 @@ def _generate_time_slot_slots( current_date = now.date() slots_per_day = {} today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - week_start = today_start - timedelta(days=now.weekday()) + from zoneinfo import ZoneInfo + tzinfo = ZoneInfo(account_timezone or 'UTC') + for day_offset in range(90): # Look 90 days ahead check_date = current_date + timedelta(days=day_offset) if check_date.weekday() not in allowed_days: continue + # Existing scheduled times for this day to avoid conflicts + existing_times = set( + Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__date=check_date + ).values_list('scheduled_publish_at', flat=True) + ) + for hour, minute in time_slots: - slot_time = timezone.make_aware( - datetime.combine(check_date, datetime.min.time().replace(hour=hour, minute=minute)) - ) + slot_time = datetime.combine(check_date, datetime.min.time().replace(hour=hour, minute=minute)) + slot_time = slot_time.replace(tzinfo=tzinfo) # Skip if in the past if slot_time <= now: continue + + # Skip if slot already occupied + if slot_time in existing_times: + continue + # Count existing scheduled/published content for this day/week/month + day_start = timezone.make_aware(datetime.combine(check_date, datetime.min.time())) + day_end = day_start + timedelta(days=1) + existing_day_count = Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__gte=day_start, + scheduled_publish_at__lt=day_end + ).count() + + week_start = day_start - timedelta(days=day_start.weekday()) + week_end = week_start + timedelta(days=7) + existing_week_count = Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__gte=week_start, + scheduled_publish_at__lt=week_end + ).count() + + month_start = day_start.replace(day=1) + if month_start.month == 12: + next_month_start = month_start.replace(year=month_start.year + 1, month=1) + else: + next_month_start = month_start.replace(month=month_start.month + 1) + existing_month_count = Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__gte=month_start, + scheduled_publish_at__lt=next_month_start + ).count() + # Check daily limit day_key = check_date.isoformat() slots_this_day = slots_per_day.get(day_key, 0) - if daily_limit and (daily_count + slots_this_day) >= daily_limit: + if daily_limit and (existing_day_count + slots_this_day) >= daily_limit: continue # Check weekly limit - slot_week_start = slot_time - timedelta(days=slot_time.weekday()) - if slot_week_start.date() == week_start.date(): - scheduled_in_week = weekly_count + len([s for s in slots if s >= week_start]) - if weekly_limit and scheduled_in_week >= weekly_limit: - continue + scheduled_in_week = existing_week_count + len([ + s for s in slots if s >= week_start and s < week_end + ]) + if weekly_limit and scheduled_in_week >= weekly_limit: + continue # Check monthly limit - if slot_time.month == now.month and slot_time.year == now.year: - scheduled_in_month = monthly_count + len([s for s in slots if s.month == now.month]) - if monthly_limit and scheduled_in_month >= monthly_limit: - continue + scheduled_in_month = existing_month_count + len([ + s for s in slots if s >= month_start and s < next_month_start + ]) + if monthly_limit and scheduled_in_month >= monthly_limit: + continue slots.append(slot_time) slots_per_day[day_key] = slots_per_day.get(day_key, 0) + 1 @@ -274,8 +292,7 @@ def _generate_time_slot_slots( def _generate_stagger_slots( settings, site, now, allowed_days, - daily_limit, weekly_limit, monthly_limit, queue_limit, - daily_count, weekly_count, monthly_count + daily_limit, weekly_limit, monthly_limit, queue_limit ) -> list: """ Generate slots spread evenly throughout the publishing window. @@ -305,7 +322,6 @@ def _generate_stagger_slots( current_date = now.date() slots_per_day = {} today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - week_start = today_start - timedelta(days=now.weekday()) for day_offset in range(90): # Look 90 days ahead check_date = current_date + timedelta(days=day_offset) @@ -329,6 +345,37 @@ def _generate_stagger_slots( scheduled_publish_at__date=check_date ).values_list('scheduled_publish_at', flat=True) ) + + # Count existing scheduled/published content for this day/week/month + day_start = timezone.make_aware(datetime.combine(check_date, datetime.min.time())) + day_end = day_start + timedelta(days=1) + existing_day_count = Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__gte=day_start, + scheduled_publish_at__lt=day_end + ).count() + + week_start = day_start - timedelta(days=day_start.weekday()) + week_end = week_start + timedelta(days=7) + existing_week_count = Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__gte=week_start, + scheduled_publish_at__lt=week_end + ).count() + + month_start = day_start.replace(day=1) + if month_start.month == 12: + next_month_start = month_start.replace(year=month_start.year + 1, month=1) + else: + next_month_start = month_start.replace(month=month_start.month + 1) + existing_month_count = Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__gte=month_start, + scheduled_publish_at__lt=next_month_start + ).count() # Start slot calculation current_slot = day_start @@ -343,23 +390,24 @@ def _generate_stagger_slots( while current_slot <= day_end: # Check daily limit slots_this_day = slots_per_day.get(day_key, 0) - if daily_limit and (daily_count + slots_this_day) >= daily_limit: + if daily_limit and (existing_day_count + slots_this_day) >= daily_limit: break # Move to next day # Check weekly limit - slot_week_start = current_slot - timedelta(days=current_slot.weekday()) - if slot_week_start.date() == week_start.date(): - scheduled_in_week = weekly_count + len([s for s in slots if s >= week_start]) - if weekly_limit and scheduled_in_week >= weekly_limit: - current_slot += interval - continue + scheduled_in_week = existing_week_count + len([ + s for s in slots if s >= week_start and s < week_end + ]) + if weekly_limit and scheduled_in_week >= weekly_limit: + current_slot += interval + continue # Check monthly limit - if current_slot.month == now.month and current_slot.year == now.year: - scheduled_in_month = monthly_count + len([s for s in slots if s.month == now.month]) - if monthly_limit and scheduled_in_month >= monthly_limit: - current_slot += interval - continue + scheduled_in_month = existing_month_count + len([ + s for s in slots if s >= month_start and s < next_month_start + ]) + if monthly_limit and scheduled_in_month >= monthly_limit: + current_slot += interval + continue # Avoid existing scheduled times if current_slot not in existing_times: diff --git a/backend/igny8_core/templates/emails/team_invite.html b/backend/igny8_core/templates/emails/team_invite.html new file mode 100644 index 00000000..dd7d4f14 --- /dev/null +++ b/backend/igny8_core/templates/emails/team_invite.html @@ -0,0 +1,31 @@ +{% extends "emails/base.html" %} +{% block title %}You're Invited to IGNY8{% endblock %} + +{% block content %} +

You're invited to join {{ account_name }}

+ +

Hi {{ invited_name }},

+ +

{{ inviter_name }} has invited you to join the {{ account_name }} workspace on IGNY8.

+ +

+ Set Your Password +

+ +
+ Next steps: + +
+ +

If the button doesn't work, copy and paste this link into your browser:

+

{{ reset_url }}

+ +

+Best regards,
+The {{ company_name|default:"IGNY8" }} Team +

+{% endblock %} diff --git a/frontend/src/components/auth/SignUpFormUnified.tsx b/frontend/src/components/auth/SignUpFormUnified.tsx index b8546f49..6838f8c5 100644 --- a/frontend/src/components/auth/SignUpFormUnified.tsx +++ b/frontend/src/components/auth/SignUpFormUnified.tsx @@ -19,6 +19,7 @@ import Input from '../form/input/InputField'; import Checkbox from '../form/input/Checkbox'; import Button from '../ui/button/Button'; import { useAuthStore } from '../../store/authStore'; +import { fetchCountries } from '../../utils/countries'; interface Plan { id: number; @@ -102,23 +103,8 @@ export default function SignUpFormUnified({ const loadCountriesAndDetect = async () => { setCountriesLoading(true); try { - const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api'; - const response = await fetch(`${API_BASE_URL}/v1/auth/countries/`); - - if (response.ok) { - const data = await response.json(); - setCountries(data.countries || []); - } else { - // Fallback countries if backend fails - setCountries([ - { code: 'US', name: 'United States' }, - { code: 'GB', name: 'United Kingdom' }, - { code: 'CA', name: 'Canada' }, - { code: 'AU', name: 'Australia' }, - { code: 'PK', name: 'Pakistan' }, - { code: 'IN', name: 'India' }, - ]); - } + const loadedCountries = await fetchCountries(); + setCountries(loadedCountries); // Try to detect user's country for default selection // Note: This may fail due to CORS - that's expected and handled gracefully @@ -137,17 +123,6 @@ export default function SignUpFormUnified({ // Silently fail - CORS or network error, keep default US // This is expected behavior and not a critical error } - } catch (err) { - console.error('Failed to load countries:', err); - // Fallback countries - setCountries([ - { code: 'US', name: 'United States' }, - { code: 'GB', name: 'United Kingdom' }, - { code: 'CA', name: 'Canada' }, - { code: 'AU', name: 'Australia' }, - { code: 'PK', name: 'Pakistan' }, - { code: 'IN', name: 'India' }, - ]); } finally { setCountriesLoading(false); } diff --git a/frontend/src/components/common/BulkScheduleModal.tsx b/frontend/src/components/common/BulkScheduleModal.tsx index 147ad218..e2e14ea1 100644 --- a/frontend/src/components/common/BulkScheduleModal.tsx +++ b/frontend/src/components/common/BulkScheduleModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Modal } from '../ui/modal'; import Button from '../ui/button/Button'; import { CalendarIcon, ClockIcon, ErrorIcon } from '../../icons'; +import { getAccountTimezone } from '../../utils/timezone'; interface Content { id: number; @@ -72,7 +73,8 @@ const BulkScheduleModal: React.FC = ({ day: 'numeric', hour: 'numeric', minute: '2-digit', - hour12: true + hour12: true, + timeZone: getAccountTimezone(), }); } catch (error) { return ''; @@ -140,7 +142,7 @@ const BulkScheduleModal: React.FC = ({ {selectedDate && selectedTime && (

- Preview: {formatPreviewDate()} + Preview: {formatPreviewDate()} ({getAccountTimezone()})

)} diff --git a/frontend/src/components/common/BulkSchedulePreviewModal.tsx b/frontend/src/components/common/BulkSchedulePreviewModal.tsx index 84a56bfd..b3a0d7bf 100644 --- a/frontend/src/components/common/BulkSchedulePreviewModal.tsx +++ b/frontend/src/components/common/BulkSchedulePreviewModal.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Modal } from '../ui/modal'; import Button from '../ui/button/Button'; import { CalendarIcon, InfoIcon, ExternalLinkIcon } from '../../icons'; +import { getAccountTimezone } from '../../utils/timezone'; interface SchedulePreviewItem { content_id: number; @@ -42,6 +43,8 @@ const BulkSchedulePreviewModal: React.FC = ({ }) => { if (!previewData) return null; + const accountTimezone = getAccountTimezone(); + const formatDate = (isoString: string) => { try { const date = new Date(isoString); @@ -51,7 +54,8 @@ const BulkSchedulePreviewModal: React.FC = ({ year: 'numeric', hour: 'numeric', minute: '2-digit', - hour12: true + hour12: true, + timeZone: accountTimezone }); } catch (error) { return isoString; @@ -68,7 +72,8 @@ const BulkSchedulePreviewModal: React.FC = ({ year: 'numeric', hour: 'numeric', minute: '2-digit', - hour12: true + hour12: true, + timeZone: accountTimezone }); } catch (error) { return isoString; @@ -102,8 +107,7 @@ const BulkSchedulePreviewModal: React.FC = ({

Using site default schedule:

    -
  • • Start time: {previewData.site_settings.base_time} ({previewData.site_settings.timezone})
  • -
  • • Stagger: {previewData.site_settings.stagger_interval} minutes between each
  • +
  • • First slot: {previewData.site_settings.base_time} ({accountTimezone})
  • • First publish: {formatFullDate(firstPublish.scheduled_at)}
  • • Last publish: {formatFullDate(lastPublish.scheduled_at)}
@@ -111,41 +115,27 @@ const BulkSchedulePreviewModal: React.FC = ({ {/* Schedule Preview */}
-

Schedule Preview:

-
-
- - - - - - - - - - {previewData.schedule_preview.map((item, index) => ( - - - - - - ))} - -
- # - - Article - - Scheduled Time -
- {index + 1} - - {item.title} - - {formatDate(item.scheduled_at)} -
-
+

Schedule Preview (first 5):

+
+
    + {previewData.schedule_preview.slice(0, 5).map((item, index) => ( +
  • + + {index + 1} + +
    +

    {item.title}

    +

    {formatDate(item.scheduled_at)}

    +
    +
  • + ))} +
+ {previewData.schedule_preview.length > 5 && ( +

+ +{previewData.schedule_preview.length - 5} more items will be scheduled in the next available slots. +

+ )}
{/* Info Box */} @@ -159,7 +149,7 @@ const BulkSchedulePreviewModal: React.FC = ({ onClick={onChangeSettings} className="text-primary-600 hover:text-primary-700 font-medium inline-flex items-center gap-1" > - Site Settings → Publishing tab + Site Settings → Automation tab

diff --git a/frontend/src/components/common/ScheduleContentModal.tsx b/frontend/src/components/common/ScheduleContentModal.tsx index 9ebc69d0..0ba32612 100644 --- a/frontend/src/components/common/ScheduleContentModal.tsx +++ b/frontend/src/components/common/ScheduleContentModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Modal } from '../ui/modal'; import Button from '../ui/button/Button'; import { CalendarIcon, ClockIcon } from '../../icons'; +import { getAccountTimezone } from '../../utils/timezone'; interface Content { id: number; @@ -84,7 +85,8 @@ const ScheduleContentModal: React.FC = ({ day: 'numeric', hour: 'numeric', minute: '2-digit', - hour12: true + hour12: true, + timeZone: getAccountTimezone(), }); } catch (error) { return ''; @@ -154,7 +156,7 @@ const ScheduleContentModal: React.FC = ({ {selectedDate && selectedTime && (

- Preview: {formatPreviewDate()} + Preview: {formatPreviewDate()} ({getAccountTimezone()})

)} diff --git a/frontend/src/constants/timezones.ts b/frontend/src/constants/timezones.ts new file mode 100644 index 00000000..7f5da15b --- /dev/null +++ b/frontend/src/constants/timezones.ts @@ -0,0 +1,127 @@ +export const COUNTRIES = [ + { value: 'US', label: 'United States' }, + { value: 'GB', label: 'United Kingdom' }, + { value: 'CA', label: 'Canada' }, + { value: 'AU', label: 'Australia' }, + { value: 'IN', label: 'India' }, + { value: 'PK', label: 'Pakistan' }, + { value: 'DE', label: 'Germany' }, + { value: 'FR', label: 'France' }, + { value: 'ES', label: 'Spain' }, + { value: 'IT', label: 'Italy' }, + { value: 'NL', label: 'Netherlands' }, + { value: 'SE', label: 'Sweden' }, + { value: 'NO', label: 'Norway' }, + { value: 'DK', label: 'Denmark' }, + { value: 'FI', label: 'Finland' }, + { value: 'BE', label: 'Belgium' }, + { value: 'AT', label: 'Austria' }, + { value: 'CH', label: 'Switzerland' }, + { value: 'IE', label: 'Ireland' }, + { value: 'NZ', label: 'New Zealand' }, + { value: 'SG', label: 'Singapore' }, + { value: 'AE', label: 'United Arab Emirates' }, + { value: 'SA', label: 'Saudi Arabia' }, + { value: 'ZA', label: 'South Africa' }, + { value: 'BR', label: 'Brazil' }, + { value: 'MX', label: 'Mexico' }, + { value: 'AR', label: 'Argentina' }, + { value: 'CL', label: 'Chile' }, + { value: 'CO', label: 'Colombia' }, + { value: 'JP', label: 'Japan' }, + { value: 'KR', label: 'South Korea' }, + { value: 'CN', label: 'China' }, + { value: 'TH', label: 'Thailand' }, + { value: 'MY', label: 'Malaysia' }, + { value: 'ID', label: 'Indonesia' }, + { value: 'PH', label: 'Philippines' }, + { value: 'VN', label: 'Vietnam' }, + { value: 'BD', label: 'Bangladesh' }, + { value: 'LK', label: 'Sri Lanka' }, + { value: 'EG', label: 'Egypt' }, + { value: 'NG', label: 'Nigeria' }, + { value: 'KE', label: 'Kenya' }, + { value: 'GH', label: 'Ghana' }, +]; + +export const COUNTRY_TIMEZONE_MAP: Record = { + US: 'America/New_York', + GB: 'Europe/London', + CA: 'America/Toronto', + AU: 'Australia/Sydney', + IN: 'Asia/Kolkata', + PK: 'Asia/Karachi', + DE: 'Europe/Berlin', + FR: 'Europe/Paris', + ES: 'Europe/Madrid', + IT: 'Europe/Rome', + NL: 'Europe/Amsterdam', + SE: 'Europe/Stockholm', + NO: 'Europe/Oslo', + DK: 'Europe/Copenhagen', + FI: 'Europe/Helsinki', + BE: 'Europe/Brussels', + AT: 'Europe/Vienna', + CH: 'Europe/Zurich', + IE: 'Europe/Dublin', + NZ: 'Pacific/Auckland', + SG: 'Asia/Singapore', + AE: 'Asia/Dubai', + SA: 'Asia/Riyadh', + ZA: 'Africa/Johannesburg', + BR: 'America/Sao_Paulo', + MX: 'America/Mexico_City', + AR: 'America/Argentina/Buenos_Aires', + CL: 'America/Santiago', + CO: 'America/Bogota', + JP: 'Asia/Tokyo', + KR: 'Asia/Seoul', + CN: 'Asia/Shanghai', + TH: 'Asia/Bangkok', + MY: 'Asia/Kuala_Lumpur', + ID: 'Asia/Jakarta', + PH: 'Asia/Manila', + VN: 'Asia/Ho_Chi_Minh', + BD: 'Asia/Dhaka', + LK: 'Asia/Colombo', + EG: 'Africa/Cairo', + NG: 'Africa/Lagos', + KE: 'Africa/Nairobi', + GH: 'Africa/Accra', +}; + +export const TIMEZONE_OPTIONS = [ + { value: 'UTC', label: 'UTC (Coordinated Universal Time)' }, + { value: 'Etc/GMT+12', label: 'UTC-12:00 (Baker Island)' }, + { value: 'Etc/GMT+11', label: 'UTC-11:00 (American Samoa)' }, + { value: 'Etc/GMT+10', label: 'UTC-10:00 (Hawaii)' }, + { value: 'Etc/GMT+9', label: 'UTC-09:00 (Alaska)' }, + { value: 'Etc/GMT+8', label: 'UTC-08:00 (Pacific Time)' }, + { value: 'Etc/GMT+7', label: 'UTC-07:00 (Mountain Time)' }, + { value: 'Etc/GMT+6', label: 'UTC-06:00 (Central Time)' }, + { value: 'Etc/GMT+5', label: 'UTC-05:00 (Eastern Time)' }, + { value: 'Etc/GMT+4', label: 'UTC-04:00 (Atlantic Time)' }, + { value: 'Etc/GMT+3', label: 'UTC-03:00 (Buenos Aires)' }, + { value: 'Etc/GMT+2', label: 'UTC-02:00 (Mid-Atlantic)' }, + { value: 'Etc/GMT+1', label: 'UTC-01:00 (Azores)' }, + { value: 'UTC', label: 'UTC+00:00 (London)' }, + { value: 'Etc/GMT-1', label: 'UTC+01:00 (Berlin, Paris)' }, + { value: 'Etc/GMT-2', label: 'UTC+02:00 (Athens, Cairo)' }, + { value: 'Etc/GMT-3', label: 'UTC+03:00 (Riyadh)' }, + { value: 'Etc/GMT-4', label: 'UTC+04:00 (Dubai)' }, + { value: 'Etc/GMT-5', label: 'UTC+05:00 (Karachi)' }, + { value: 'Etc/GMT-6', label: 'UTC+06:00 (Dhaka)' }, + { value: 'Etc/GMT-7', label: 'UTC+07:00 (Bangkok)' }, + { value: 'Etc/GMT-8', label: 'UTC+08:00 (Singapore)' }, + { value: 'Etc/GMT-9', label: 'UTC+09:00 (Tokyo)' }, + { value: 'Etc/GMT-10', label: 'UTC+10:00 (Sydney)' }, + { value: 'Etc/GMT-11', label: 'UTC+11:00 (Solomon Islands)' }, + { value: 'Etc/GMT-12', label: 'UTC+12:00 (Auckland)' }, + { value: 'Etc/GMT-13', label: 'UTC+13:00 (Samoa)' }, + { value: 'Etc/GMT-14', label: 'UTC+14:00 (Line Islands)' }, +]; + +export const getTimezoneForCountry = (countryCode?: string): string => { + if (!countryCode) return 'UTC'; + return COUNTRY_TIMEZONE_MAP[countryCode.toUpperCase()] || 'UTC'; +}; diff --git a/frontend/src/pages/Automation/AutomationPage.tsx b/frontend/src/pages/Automation/AutomationPage.tsx index e03b38c2..c4d102af 100644 --- a/frontend/src/pages/Automation/AutomationPage.tsx +++ b/frontend/src/pages/Automation/AutomationPage.tsx @@ -22,6 +22,8 @@ import PageHeader from '../../components/common/PageHeader'; import ComponentCard from '../../components/common/ComponentCard'; import DebugSiteSelector from '../../components/common/DebugSiteSelector'; import Button from '../../components/ui/button/Button'; +import { formatDate, formatDateTime } from '../../utils/date'; +import { getAccountTimezone } from '../../utils/timezone'; import { BoltIcon, ListIcon, @@ -81,7 +83,7 @@ const AutomationPage: React.FC = () => { // Server time state - shows the actual time used for all operations const [serverTime, setServerTime] = useState(null); - const [serverTimezone, setServerTimezone] = useState('UTC'); + const accountTimezone = getAccountTimezone(); // Track site ID to avoid duplicate calls when activeSite object reference changes const siteId = activeSite?.id; @@ -91,8 +93,7 @@ const AutomationPage: React.FC = () => { const loadServerTime = async () => { try { const data = await automationService.getServerTime(); - setServerTime(data.server_time_formatted); - setServerTimezone(data.timezone); + setServerTime(data.server_time); } catch (error) { console.error('Failed to load server time:', error); } @@ -111,12 +112,14 @@ const AutomationPage: React.FC = () => { const getNextRunTime = (config: AutomationConfig): string => { if (!config.is_enabled || !config.scheduled_time) return ''; - const now = new Date(); + const now = serverTime ? new Date(serverTime) : new Date(); const [schedHours, schedMinutes] = config.scheduled_time.split(':').map(Number); - // Create next run date - const nextRun = new Date(); - nextRun.setUTCHours(schedHours, schedMinutes, 0, 0); + // Prefer server-provided next run time if available + const nextRun = config.next_run_at ? new Date(config.next_run_at) : new Date(); + if (!config.next_run_at) { + nextRun.setUTCHours(schedHours, schedMinutes, 0, 0); + } // If scheduled time has passed today, set to tomorrow if (nextRun <= now) { @@ -145,6 +148,17 @@ const AutomationPage: React.FC = () => { } }; + const formatTime = (value: string | null) => { + if (!value) return '--:--'; + const date = new Date(value); + if (isNaN(date.getTime())) return '--:--'; + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + timeZone: accountTimezone, + }); + }; + useEffect(() => { if (!siteId) return; // Reset state when site changes @@ -600,7 +614,7 @@ const AutomationPage: React.FC = () => {
- Last: {config.last_run_at ? new Date(config.last_run_at).toLocaleDateString() : 'Never'} + Last: {config.last_run_at ? formatDate(config.last_run_at) : 'Never'}
{config.is_enabled && ( <> @@ -613,7 +627,7 @@ const AutomationPage: React.FC = () => {
- {serverTime ? serverTime.substring(0, 5) : '--:--'} + {formatTime(serverTime)}
@@ -632,7 +646,7 @@ const AutomationPage: React.FC = () => { {!currentRun && totalPending === 0 && 'No Items Pending'} 0 || currentRun ? 'text-gray-600' : 'text-white/70'}`}> - {currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')} + {currentRun ? `Started: ${formatDateTime(currentRun.started_at)}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')} diff --git a/frontend/src/pages/Publisher/ContentCalendar.tsx b/frontend/src/pages/Publisher/ContentCalendar.tsx index 364d05e3..6e2b98f1 100644 --- a/frontend/src/pages/Publisher/ContentCalendar.tsx +++ b/frontend/src/pages/Publisher/ContentCalendar.tsx @@ -71,7 +71,7 @@ export default function ContentCalendar() { // Schedule modal state const [showScheduleModal, setShowScheduleModal] = useState(false); - const [scheduleContent, setScheduleContent] = useState(null); + const [scheduledContentItem, setScheduledContentItem] = useState(null); const [isRescheduling, setIsRescheduling] = useState(false); // Derived state: Queue items (scheduled or publishing - exclude already published) @@ -87,11 +87,6 @@ export default function ContentCalendar() { return dateA - dateB; }); - console.log('[ContentCalendar] queueItems (derived):', items.length, 'items'); - items.forEach(item => { - console.log(' Queue item:', item.id, item.title, 'scheduled:', item.scheduled_publish_at); - }); - return items; }, [allContent]); @@ -122,11 +117,6 @@ export default function ContentCalendar() { return dateB - dateA; }); - console.log('[ContentCalendar] failedItems (derived):', items.length, 'items'); - items.forEach(item => { - console.log(' Failed item:', item.id, item.title, 'error:', item.site_status_message); - }); - return items; }, [allContent]); @@ -136,14 +126,9 @@ export default function ContentCalendar() { const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); - // DEBUG: Check scheduled items in stats calculation const scheduledItems = allContent.filter((c: Content) => c.site_status === 'scheduled' && (!c.external_id || c.external_id === '') ); - console.log('[ContentCalendar] STATS CALCULATION - Scheduled items:', scheduledItems.length); - scheduledItems.forEach(c => { - console.log(' Stats scheduled item:', c.id, c.title, 'external_id:', c.external_id); - }); // Published in last 30 days - check EITHER external_id OR site_status='published' const publishedLast30Days = allContent.filter((c: Content) => { @@ -182,10 +167,7 @@ export default function ContentCalendar() { }, [allContent]); const loadQueue = useCallback(async () => { - if (!activeSite?.id) { - console.log('[ContentCalendar] No active site selected, skipping load'); - return; - } + if (!activeSite?.id) return; try { setLoading(true); @@ -196,11 +178,6 @@ export default function ContentCalendar() { const siteId = activeSite.id; - console.log('[ContentCalendar] ========== SITE FILTERING DEBUG =========='); - console.log('[ContentCalendar] Active site ID:', siteId); - console.log('[ContentCalendar] Active site name:', activeSite.name); - console.log('[ContentCalendar] Fetching content with multiple targeted queries...'); - // Fetch scheduled items (all of them, regardless of page) const scheduledResponse = await fetchAPI( `/v1/writer/content/?site_id=${siteId}&page_size=1000&site_status=scheduled` @@ -240,34 +217,6 @@ export default function ContentCalendar() { new Map(allItems.map(item => [item.id, item])).values() ); - // Debug: Comprehensive logging - console.log('[ContentCalendar] ========== DATA LOAD DEBUG =========='); - console.log('[ContentCalendar] Scheduled query returned:', scheduledResponse.results?.length, 'items'); - console.log('[ContentCalendar] Failed query returned:', failedResponse.results?.length, 'items'); - console.log('[ContentCalendar] Review query returned:', reviewResponse.results?.length, 'items'); - console.log('[ContentCalendar] Approved query returned:', approvedResponse.results?.length, 'items'); - console.log('[ContentCalendar] Published query returned:', publishedResponse.results?.length, 'items'); - console.log('[ContentCalendar] Total unique items after deduplication:', uniqueItems.length); - - console.log('[ContentCalendar] ALL SCHEDULED ITEMS DETAILS:'); - scheduledResponse.results?.forEach(c => { - console.log(' - ID:', c.id, '| Title:', c.title); - console.log(' status:', c.status, '| site_status:', c.site_status); - console.log(' scheduled_publish_at:', c.scheduled_publish_at); - console.log(' external_id:', c.external_id); - console.log(' ---'); - }); - - console.log('[ContentCalendar] ALL FAILED ITEMS DETAILS:'); - failedResponse.results?.forEach(c => { - console.log(' - ID:', c.id, '| Title:', c.title); - console.log(' status:', c.status, '| site_status:', c.site_status); - console.log(' site_status_message:', c.site_status_message); - console.log(' scheduled_publish_at:', c.scheduled_publish_at); - console.log(' ---'); - }); - console.log('[ContentCalendar] ===================================='); - setAllContent(uniqueItems); } catch (error: any) { toast.error(`Failed to load content: ${error.message}`); @@ -279,11 +228,8 @@ export default function ContentCalendar() { // Load queue when active site changes useEffect(() => { if (activeSite?.id) { - console.log('[ContentCalendar] Site changed to:', activeSite.id, activeSite.name); - console.log('[ContentCalendar] Triggering loadQueue...'); loadQueue(); } else { - console.log('[ContentCalendar] No active site, clearing content'); setAllContent([]); } }, [activeSite?.id]); // Only depend on activeSite.id, loadQueue is stable @@ -306,7 +252,7 @@ export default function ContentCalendar() { // Open reschedule modal const openRescheduleModal = useCallback((item: Content) => { - setScheduleContent(item); + setScheduledContentItem(item); setIsRescheduling(true); setShowScheduleModal(true); }, []); @@ -321,7 +267,7 @@ export default function ContentCalendar() { loadQueue(); } setShowScheduleModal(false); - setScheduleContent(null); + setScheduledContentItem(null); setIsRescheduling(false); }, [isRescheduling, handleRescheduleContent, toast, loadQueue]); @@ -687,10 +633,14 @@ export default function ContentCalendar() { tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(9, 0, 0, 0); scheduleContent(draggedItem.id, tomorrow.toISOString()) - .then((updatedContent) => { + .then((response) => { toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`); setAllContent(prevContent => [ - ...prevContent.map(c => c.id === draggedItem.id ? updatedContent : c) + ...prevContent.map(c => c.id === draggedItem.id ? { + ...c, + site_status: response.site_status, + scheduled_publish_at: response.scheduled_publish_at, + } : c) ]); }) .catch((err) => toast.error(`Failed to schedule: ${err.message}`)); @@ -1008,15 +958,15 @@ export default function ContentCalendar() { {/* Schedule/Reschedule Modal */} - {showScheduleModal && scheduleContent && ( + {showScheduleModal && scheduledContentItem && ( { setShowScheduleModal(false); - setScheduleContent(null); + setScheduledContentItem(null); setIsRescheduling(false); }} - content={scheduleContent} + content={scheduledContentItem} onSchedule={handleScheduleFromModal} mode={isRescheduling ? 'reschedule' : 'schedule'} /> diff --git a/frontend/src/pages/Sites/AIAutomationSettings.tsx b/frontend/src/pages/Sites/AIAutomationSettings.tsx index 8665e67e..cbd1304e 100644 --- a/frontend/src/pages/Sites/AIAutomationSettings.tsx +++ b/frontend/src/pages/Sites/AIAutomationSettings.tsx @@ -1,3 +1,4 @@ +import { getAccountTimezone } from '../../utils/timezone'; /** * AI & Automation Settings Component * Per SETTINGS-CONSOLIDATION-PLAN.md @@ -914,11 +915,11 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
Timezone -

UTC

+

{getAccountTimezone()}

- The scheduler runs at 5 minutes past each hour. Select the hour you want your automation to run — for example, selecting 2 PM means it will run at 2:05 PM UTC. Automations only run once per day. If it has already run today, the next run will be tomorrow (or the next scheduled day for weekly/monthly). + The scheduler runs at 5 minutes past each hour. Select the hour you want your automation to run — for example, selecting 2 PM means it will run at 2:05 PM {getAccountTimezone()}. Automations only run once per day. If it has already run today, the next run will be tomorrow (or the next scheduled day for weekly/monthly).

diff --git a/frontend/src/pages/Writer/Approved.tsx b/frontend/src/pages/Writer/Approved.tsx index d53f2c98..4deaffb8 100644 --- a/frontend/src/pages/Writer/Approved.tsx +++ b/frontend/src/pages/Writer/Approved.tsx @@ -474,7 +474,7 @@ export default function Approved() { // Open site settings in new tab const handleOpenSiteSettings = useCallback(() => { if (activeSite) { - window.open(`/sites/${activeSite.id}/settings?tab=publishing`, '_blank'); + window.open(`/sites/${activeSite.id}/settings?tab=automation`, '_blank'); } }, [activeSite]); @@ -630,13 +630,16 @@ export default function Approved() { if (action === 'bulk_publish_site') { await handleBulkPublishToSite(ids); } else if (action === 'bulk_schedule_manual') { - // Manual bulk scheduling (same time for all) - handleBulkScheduleManual(ids); + // Manual bulk scheduling (same time for all) via modal + const numericIds = ids.map(id => parseInt(id)); + const items = content.filter(item => numericIds.includes(item.id)); + setBulkScheduleItems(items); + setShowBulkScheduleModal(true); } else if (action === 'bulk_schedule_defaults') { // Schedule with site defaults handleBulkScheduleWithDefaults(ids); } - }, [handleBulkPublishToSite, handleBulkScheduleManual, handleBulkScheduleWithDefaults]); + }, [handleBulkPublishToSite, handleBulkScheduleWithDefaults, content]); // Bulk status update handler const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => { diff --git a/frontend/src/pages/account/AccountSettingsPage.tsx b/frontend/src/pages/account/AccountSettingsPage.tsx index 7d13d90f..a17783f6 100644 --- a/frontend/src/pages/account/AccountSettingsPage.tsx +++ b/frontend/src/pages/account/AccountSettingsPage.tsx @@ -20,6 +20,8 @@ import { Modal } from '../../components/ui/modal'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { useAuthStore } from '../../store/authStore'; import { usePageLoading } from '../../context/PageLoadingContext'; +import { TIMEZONE_OPTIONS, getTimezoneForCountry } from '../../constants/timezones'; +import { fetchCountries } from '../../utils/countries'; import { getAccountSettings, updateAccountSettings, @@ -27,6 +29,7 @@ import { inviteTeamMember, removeTeamMember, getUserProfile, + updateUserProfile, changePassword, type AccountSettings, type TeamMember, @@ -40,6 +43,8 @@ export default function AccountSettingsPage() { const [savingProfile, setSavingProfile] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); + const [countries, setCountries] = useState>([]); + const [countriesLoading, setCountriesLoading] = useState(false); // Account settings state const [settings, setSettings] = useState(null); @@ -53,6 +58,9 @@ export default function AccountSettingsPage() { billing_country: '', tax_id: '', billing_email: '', + account_timezone: 'UTC', + timezone_mode: 'country' as 'country' | 'manual', + timezone_offset: '', }); // Profile settings state @@ -61,7 +69,6 @@ export default function AccountSettingsPage() { lastName: '', email: '', phone: '', - timezone: 'America/New_York', language: 'en', emailNotifications: true, marketingEmails: false, @@ -87,28 +94,26 @@ export default function AccountSettingsPage() { last_name: '', }); - // Load profile from auth store user data - useEffect(() => { - if (user) { - const [firstName = '', lastName = ''] = (user.username || '').split(' '); - setProfileForm({ - firstName: firstName, - lastName: lastName, - email: user.email || '', - phone: '', - timezone: 'America/New_York', - language: 'en', - emailNotifications: true, - marketingEmails: false, - }); - } - }, [user]); - useEffect(() => { loadData(); loadTeamMembers(); + loadProfile(); + loadCountries(); }, []); + // Load profile from auth store user data (fallback if API not ready) + useEffect(() => { + if (user && !profileForm.email) { + const [firstName = '', lastName = ''] = (user.username || '').split(' '); + setProfileForm((prev) => ({ + ...prev, + firstName, + lastName, + email: user.email || '', + })); + } + }, [user, profileForm.email]); + const loadData = async () => { try { startLoading('Loading settings...'); @@ -124,6 +129,9 @@ export default function AccountSettingsPage() { billing_country: accountData.billing_country || '', tax_id: accountData.tax_id || '', billing_email: accountData.billing_email || '', + account_timezone: accountData.account_timezone || 'UTC', + timezone_mode: accountData.timezone_mode || 'country', + timezone_offset: accountData.timezone_offset || '', }); } catch (err: any) { setError(err.message || 'Failed to load settings'); @@ -144,6 +152,36 @@ export default function AccountSettingsPage() { } }; + const loadCountries = async () => { + try { + setCountriesLoading(true); + const loadedCountries = await fetchCountries(); + setCountries(loadedCountries); + } catch (err: any) { + console.error('Failed to load countries:', err); + } finally { + setCountriesLoading(false); + } + }; + + const loadProfile = async () => { + try { + const response = await getUserProfile(); + const profile = response.user; + setProfileForm({ + firstName: profile.first_name || '', + lastName: profile.last_name || '', + email: profile.email || '', + phone: profile.phone || '', + language: profile.language || 'en', + emailNotifications: profile.email_notifications ?? true, + marketingEmails: profile.marketing_emails ?? false, + }); + } catch (err: any) { + console.error('Failed to load profile:', err); + } + }; + const handleAccountSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { @@ -154,6 +192,7 @@ export default function AccountSettingsPage() { setSuccess('Account settings updated successfully'); toast.success('Account settings saved'); await loadData(); + await refreshUser(); } catch (err: any) { setError(err.message || 'Failed to update account settings'); toast.error(err.message || 'Failed to save settings'); @@ -166,8 +205,12 @@ export default function AccountSettingsPage() { e.preventDefault(); try { setSavingProfile(true); - // Profile data is stored in auth user - refresh after save - // Note: Full profile API would go here when backend supports it + await updateUserProfile({ + first_name: profileForm.firstName, + last_name: profileForm.lastName, + email: profileForm.email, + phone: profileForm.phone, + }); toast.success('Profile settings saved'); await refreshUser(); } catch (err: any) { @@ -245,6 +288,25 @@ export default function AccountSettingsPage() { })); }; + const handleCountryChange = (value: string) => { + const derivedTimezone = getTimezoneForCountry(value); + setAccountForm(prev => ({ + ...prev, + billing_country: value, + account_timezone: prev.timezone_mode === 'country' ? derivedTimezone : prev.account_timezone, + })); + }; + + const handleTimezoneModeChange = (value: string) => { + const mode = value === 'manual' ? 'manual' : 'country'; + setAccountForm(prev => ({ + ...prev, + timezone_mode: mode, + account_timezone: mode === 'country' ? getTimezoneForCountry(prev.billing_country) : prev.account_timezone, + timezone_offset: mode === 'country' ? '' : prev.timezone_offset, + })); + }; + return ( <> @@ -334,13 +396,58 @@ export default function AccountSettingsPage() { value={accountForm.billing_postal_code} onChange={handleAccountChange} /> - +
+ + +
+ {accountForm.timezone_mode === 'manual' ? ( +
+ + setProfileForm({ ...profileForm, timezone: value })} - /> -