diff --git a/backend/api/onboarding_utils/endpoints_config_data.py b/backend/api/onboarding_utils/endpoints_config_data.py index cc37acd5..c2d82aa0 100644 --- a/backend/api/onboarding_utils/endpoints_config_data.py +++ b/backend/api/onboarding_utils/endpoints_config_data.py @@ -222,6 +222,94 @@ async def update_business_info(business_info_id: int, business_info: dict): raise HTTPException(status_code=500, detail=f"Failed to update business info: {str(e)}") +async def generate_website_preview(intake: Dict[str, Any], current_user: Dict[str, Any]): + try: + user_id = current_user.get("id") + from services.onboarding.website_intake_service import website_intake_service + from services.onboarding.website_style_service import website_style_service + from api.onboarding_utils.website_automation_service import website_automation_service + from services.user_website_service import user_website_service + from models.user_website_request import UserWebsiteRequest, WebsiteStatus, TemplateType + + existing = user_website_service.get_user_website_by_user(user_id) + if not existing: + user_website_service.create_user_website( + UserWebsiteRequest( + user_id=user_id, + template_type=TemplateType(intake.get("template_type", "blog")), + status=WebsiteStatus.PREVIEWING, + business_name=intake.get("business_name"), + business_description=intake.get("business_summary") + ) + ) + + site_brief = website_intake_service.generate_site_brief(intake, user_id=str(user_id)) + if existing and existing.netlify_site_url: + site_brief.setdefault("site_brief", {}) + site_brief["site_brief"]["canonical_url"] = existing.netlify_site_url + tokens = website_style_service.generate_theme_tokens(site_brief, user_id=str(user_id)) + css = website_style_service.render_css(tokens) if tokens and not tokens.get("error") else "" + preview = await website_automation_service.generate_preview_site(user_id, site_brief, css) + + return { + "site_brief": site_brief, + "theme_tokens": tokens, + "css": css, + "preview_url": preview.get("preview_url"), + "preview_root": preview.get("preview_root"), + "preview_files": preview.get("preview_files"), + "preview_html": preview.get("preview_html"), + } + except Exception as e: + logger.error(f"Error generating website preview: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to generate website preview") + + +async def deploy_website(intake: Dict[str, Any], current_user: Dict[str, Any]): + try: + user_id = current_user.get("id") + from api.onboarding_utils.website_automation_service import WebsiteAutomationService + from services.user_website_service import user_website_service + from services.onboarding.website_intake_service import website_intake_service + from services.onboarding.website_style_service import website_style_service + from models.user_website_request import WebsiteStatusUpdate + + template = intake.get("template_type", "blog") + business_name = intake.get("business_name") or intake.get("business_summary") or f"ALwrity Site {user_id}" + business_info = {"name": business_name} + site_brief = website_intake_service.generate_site_brief(intake, user_id=str(user_id)) + tokens = website_style_service.generate_theme_tokens(site_brief, user_id=str(user_id)) + css = website_style_service.render_css(tokens) if tokens and not tokens.get("error") else "" + + service = WebsiteAutomationService() + result = await service.generate_website( + user_id, + business_info, + template, + site_brief=site_brief, + css=css + ) + + user_website_service.update_user_website_status( + user_id=user_id, + status_update=WebsiteStatusUpdate( + status=WebsiteStatus.DEPLOYED, + github_repo_url=result.get("repo_url"), + netlify_site_url=result.get("live_url"), + netlify_admin_url=result.get("admin_url") + ) + ) + return { + **result, + "site_brief": site_brief, + "theme_tokens": tokens, + "css": css + } + except Exception as e: + logger.error(f"Error deploying website: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to deploy website") + + __all__ = [name for name in globals().keys() if not name.startswith('_')] diff --git a/backend/api/onboarding_utils/endpoints_core.py b/backend/api/onboarding_utils/endpoints_core.py index acd9872e..a742bf39 100644 --- a/backend/api/onboarding_utils/endpoints_core.py +++ b/backend/api/onboarding_utils/endpoints_core.py @@ -18,7 +18,7 @@ async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_curre logger.error("initialize_onboarding called without a valid current_user") raise HTTPException(status_code=401, detail="User not authenticated") - user_id = str(current_user.get('id')) + user_id = str(current_user.get('clerk_user_id') or current_user.get('id')) progress_service = OnboardingProgressService() status = progress_service.get_onboarding_status(user_id) @@ -96,7 +96,7 @@ async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_curre "email": current_user.get('email'), "first_name": current_user.get('first_name'), "last_name": current_user.get('last_name'), - "clerk_user_id": user_id, + "clerk_user_id": str(current_user.get('clerk_user_id') or user_id), }, "onboarding": { "is_completed": status['is_completed'], diff --git a/backend/api/onboarding_utils/onboarding_control_service.py b/backend/api/onboarding_utils/onboarding_control_service.py index 0435117f..d16758b9 100644 --- a/backend/api/onboarding_utils/onboarding_control_service.py +++ b/backend/api/onboarding_utils/onboarding_control_service.py @@ -22,7 +22,7 @@ class OnboardingControlService: db_gen = get_db() db = next(db_gen) try: - user_id = str(current_user.get('id')) + user_id = str(current_user.get('clerk_user_id') or current_user.get('id')) # Ensure user workspace exists when starting onboarding try: @@ -53,7 +53,7 @@ class OnboardingControlService: """Reset the onboarding progress for a specific user.""" try: from services.onboarding.progress_service import OnboardingProgressService - user_id = str(current_user.get('id')) + user_id = str(current_user.get('clerk_user_id') or current_user.get('id')) progress_service = OnboardingProgressService() success = progress_service.reset_onboarding(user_id) diff --git a/backend/api/onboarding_utils/step_management_service.py b/backend/api/onboarding_utils/step_management_service.py index e8d027cb..dc1726b6 100644 --- a/backend/api/onboarding_utils/step_management_service.py +++ b/backend/api/onboarding_utils/step_management_service.py @@ -416,7 +416,7 @@ class StepManagementService: async def get_step_data(self, step_number: int, current_user: Dict[str, Any]) -> Dict[str, Any]: """Get data for a specific step.""" try: - user_id = str(current_user.get('id')) + user_id = str(current_user.get('clerk_user_id') or current_user.get('id')) db = next(get_db(current_user)) # Use SSOT for reading step data @@ -492,7 +492,7 @@ class StepManagementService: """Mark a step as completed.""" try: logger.info(f"[complete_step] Completing step {step_number}") - user_id = str(current_user.get('id')) + user_id = str(current_user.get('clerk_user_id') or current_user.get('id')) # Optional validation try: @@ -672,7 +672,7 @@ class StepManagementService: """Skip a step (for optional steps).""" try: from services.onboarding.api_key_manager import get_onboarding_progress_for_user - user_id = str(current_user.get('id')) + user_id = str(current_user.get('clerk_user_id') or current_user.get('id')) progress = get_onboarding_progress_for_user(user_id) step = progress.get_step_data(step_number) @@ -695,7 +695,7 @@ class StepManagementService: async def validate_step_access(self, step_number: int, current_user: Dict[str, Any]) -> Dict[str, Any]: """Validate if user can access a specific step.""" try: - user_id = str(current_user.get('id')) + user_id = str(current_user.get('clerk_user_id') or current_user.get('id')) progress = get_onboarding_progress_for_user(user_id) if not progress.can_proceed_to_step(step_number): diff --git a/backend/api/onboarding_utils/website_automation_service.py b/backend/api/onboarding_utils/website_automation_service.py new file mode 100644 index 00000000..aeed3c10 --- /dev/null +++ b/backend/api/onboarding_utils/website_automation_service.py @@ -0,0 +1,250 @@ +"""Website Automation Service for API layer - orchestrates website creation.""" +from typing import Dict, Any, Optional +from loguru import logger +import os +import tempfile +import json +from fastapi import HTTPException + +# Import the actual automation service +from services.onboarding.website_automation_service import WebsiteAutomationService as CoreAutomationService + + +class WebsiteAutomationService: + """API layer service for website automation operations.""" + + def __init__(self): + logger.info("🔄 Initializing WebsiteAutomationService (API layer)...") + self.core_service = CoreAutomationService() + + async def generate_preview_site( + self, + user_id: str, + site_brief: Dict[str, Any], + css: str + ) -> Dict[str, Any]: + """Generate a preview site for the user.""" + try: + logger.info(f"Generating preview site for user {user_id}") + + # For preview, we'll create a temporary HTML file + # In production, this could be hosted on a preview server + preview_html = self._generate_preview_html(site_brief, css) + + # Save to temporary file (in production, use proper hosting) + preview_url = f"/preview/{user_id}/index.html" + + return { + "preview_url": preview_url, + "preview_root": f"/preview/{user_id}", + "preview_files": ["index.html", "custom.css"], + "preview_html": preview_html + } + + except Exception as e: + logger.error(f"Failed to generate preview site: {str(e)}") + raise HTTPException(status_code=500, detail=f"Preview generation failed: {str(e)}") + + def _generate_preview_html(self, site_brief: Dict[str, Any], css: str) -> str: + """Generate HTML preview from site brief and CSS.""" + try: + site_data = site_brief.get("site_brief", {}) + business_name = site_data.get("business_name", "Your Business") + tagline = site_data.get("tagline", "Your tagline here") + + # Get content plan + content_plan = site_brief.get("content_plan", {}) + required_pages = content_plan.get("required_pages", []) + + # Generate HTML + html = f""" + + + + + {business_name} + + + +
+

{business_name}

+

{tagline}

+
ALwrity Preview
+
+ +
+ {self._generate_page_content(required_pages)} +
+ + + +""" + + return html + + except Exception as e: + logger.error(f"Failed to generate preview HTML: {str(e)}") + return f"

Preview Error

{str(e)}

" + + def _generate_page_content(self, required_pages: list) -> str: + """Generate HTML content for pages.""" + if not required_pages: + return """ +
+

Welcome to Your Website

+

This is a preview of your new website. The content will be generated based on your business information.

+
+
+

About Us

+

Learn more about your business and what makes you unique.

+
+
+

Services

+

Discover the services and products you offer to your customers.

+
+
+

Contact

+

Get in touch with you through various contact methods.

+
+
+
+ """ + + content_parts = [] + + for page in required_pages: + page_name = page.get("page", "page").title() + goal = page.get("goal", "") + key_points = page.get("key_points", []) + cta = page.get("cta", "Get Started") + + page_html = f""" +
+

{page_name}

+

{goal}

+ """ + + if key_points: + page_html += "
" + for point in key_points: + page_html += f""" +
+

{point}

+
+ """ + page_html += "
" + + if cta: + page_html += f""" +
+ +
+ """ + + page_html += "
" + content_parts.append(page_html) + + return "".join(content_parts) + + async def generate_website( + self, + user_id: str, + business_info: Dict[str, Any], + niche: str, + site_brief: Optional[Dict[str, Any]] = None, + css: Optional[str] = None + ) -> Dict[str, str]: + """Generate and deploy a full website.""" + try: + logger.info(f"Generating website for user {user_id}") + + # Use the core automation service + result = await self.core_service.generate_website( + user_id=user_id, + business_info=business_info, + niche=niche, + site_brief=site_brief, + css=css + ) + + return result + + except Exception as e: + logger.error(f"Failed to generate website: {str(e)}") + raise HTTPException(status_code=500, detail=f"Website generation failed: {str(e)}") + + def get_deployment_status(self, user_id: str) -> Dict[str, Any]: + """Get the status of website deployment.""" + try: + # This would typically check the deployment status + # For now, return a placeholder + return { + "status": "pending", + "message": "Deployment status checking not yet implemented" + } + except Exception as e: + logger.error(f"Failed to get deployment status: {str(e)}") + return { + "status": "error", + "message": str(e) + } + + +# Singleton instance +website_automation_service = WebsiteAutomationService() diff --git a/backend/models/user_website_request.py b/backend/models/user_website_request.py new file mode 100644 index 00000000..c2db77b9 --- /dev/null +++ b/backend/models/user_website_request.py @@ -0,0 +1,121 @@ +"""User Website Request models for ALwrity website maker functionality.""" +from typing import Optional, Dict, Any +from pydantic import BaseModel, Field +from datetime import datetime +from enum import Enum + + +class WebsiteStatus(str, Enum): + """Website creation status enum.""" + INITIATED = "initiated" + PREVIEWING = "previewing" + DEPLOYING = "deploying" + DEPLOYED = "deployed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class TemplateType(str, Enum): + """Website template types.""" + BLOG = "blog" + PROFILE = "profile" + SHOP = "shop" + DONT_KNOW = "dont_know" + + +class UserWebsiteRequest(BaseModel): + """Request model for creating/updating user website.""" + user_id: int = Field(..., description="User ID") + template_type: TemplateType = Field(default=TemplateType.BLOG, description="Website template type") + business_name: Optional[str] = Field(None, description="Business name") + business_description: Optional[str] = Field(None, description="Business description") + status: WebsiteStatus = Field(default=WebsiteStatus.INITIATED, description="Website status") + github_repo_url: Optional[str] = Field(None, description="GitHub repository URL") + netlify_site_url: Optional[str] = Field(None, description="Netlify deployed site URL") + netlify_admin_url: Optional[str] = Field(None, description="Netlify admin URL") + site_brief: Optional[Dict[str, Any]] = Field(None, description="Generated site brief") + theme_tokens: Optional[Dict[str, Any]] = Field(None, description="Theme configuration tokens") + custom_css: Optional[str] = Field(None, description="Custom CSS for theming") + preview_url: Optional[str] = Field(None, description="Preview site URL") + deployment_config: Optional[Dict[str, Any]] = Field(None, description="Deployment configuration") + error_message: Optional[str] = Field(None, description="Error message if failed") + + +class UserWebsiteResponse(BaseModel): + """Response model for user website data.""" + id: int + user_id: int + template_type: TemplateType + business_name: Optional[str] + business_description: Optional[str] + status: WebsiteStatus + github_repo_url: Optional[str] + netlify_site_url: Optional[str] + netlify_admin_url: Optional[str] + site_brief: Optional[Dict[str, Any]] + theme_tokens: Optional[Dict[str, Any]] + custom_css: Optional[str] + preview_url: Optional[str] + deployment_config: Optional[Dict[str, Any]] + error_message: Optional[str] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class WebsitePreviewRequest(BaseModel): + """Request model for generating website preview.""" + user_id: int + intake_data: Dict[str, Any] = Field(..., description="Business intake data") + template_type: Optional[TemplateType] = Field(None, description="Template type override") + + +class WebsiteDeploymentRequest(BaseModel): + """Request model for deploying website.""" + user_id: int + intake_data: Dict[str, Any] = Field(..., description="Business intake data") + template_type: Optional[TemplateType] = Field(None, description="Template type override") + custom_domain: Optional[str] = Field(None, description="Custom domain for deployment") + + +class WebsiteStatusUpdate(BaseModel): + """Request model for updating website status.""" + status: WebsiteStatus + github_repo_url: Optional[str] = None + netlify_site_url: Optional[str] = None + netlify_admin_url: Optional[str] = None + preview_url: Optional[str] = None + error_message: Optional[str] = None + + +# Database model for SQLAlchemy +class UserWebsite: + """SQLAlchemy model for UserWebsite table.""" + + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def to_dict(self) -> Dict[str, Any]: + """Convert model to dictionary.""" + return { + 'id': getattr(self, 'id', None), + 'user_id': getattr(self, 'user_id', None), + 'template_type': getattr(self, 'template_type', None), + 'business_name': getattr(self, 'business_name', None), + 'business_description': getattr(self, 'business_description', None), + 'status': getattr(self, 'status', None), + 'github_repo_url': getattr(self, 'github_repo_url', None), + 'netlify_site_url': getattr(self, 'netlify_site_url', None), + 'netlify_admin_url': getattr(self, 'netlify_admin_url', None), + 'site_brief': getattr(self, 'site_brief', None), + 'theme_tokens': getattr(self, 'theme_tokens', None), + 'custom_css': getattr(self, 'custom_css', None), + 'preview_url': getattr(self, 'preview_url', None), + 'deployment_config': getattr(self, 'deployment_config', None), + 'error_message': getattr(self, 'error_message', None), + 'created_at': getattr(self, 'created_at', None), + 'updated_at': getattr(self, 'updated_at', None), + } diff --git a/backend/services/onboarding/api_key_manager.py b/backend/services/onboarding/api_key_manager.py index 5f6dcd04..62f13074 100644 --- a/backend/services/onboarding/api_key_manager.py +++ b/backend/services/onboarding/api_key_manager.py @@ -224,24 +224,27 @@ class OnboardingProgress: def _save_api_key_to_db(self, db, provider: str, key: str): """Save API key to database.""" try: + session = db.query(OnboardingSession).filter(OnboardingSession.user_id == self.user_id).first() + if not session: + logger.warning(f"No session found for user {self.user_id} when saving API key") + return + api_key_record = db.query(APIKey).filter( - APIKey.user_id == self.user_id, + APIKey.session_id == session.id, APIKey.provider == provider ).first() - + if not api_key_record: api_key_record = APIKey( - user_id=self.user_id, - provider=provider, - api_key=key, - is_active=True, - created_at=datetime.utcnow() + session_id=session.id, + provider=provider, + key=key, ) db.add(api_key_record) else: - api_key_record.api_key = key + api_key_record.key = key api_key_record.updated_at = datetime.utcnow() - + db.commit() except Exception as e: logger.error(f"Error saving API key to DB: {e}") diff --git a/backend/services/onboarding/website_automation_service.py b/backend/services/onboarding/website_automation_service.py new file mode 100644 index 00000000..8815bef4 --- /dev/null +++ b/backend/services/onboarding/website_automation_service.py @@ -0,0 +1,206 @@ +"""Core Website Automation Service for actual GitHub/Netlify deployment.""" +import os +import httpx +from typing import Dict, Any, Optional +from loguru import logger +from fastapi import HTTPException + +# GitHub token and Netlify token should be in environment variables +GITHUB_TOKEN = os.getenv("GITHUB_ACCESS_TOKEN") +NETLIFY_TOKEN = os.getenv("NETLIFY_ACCESS_TOKEN") +NETLIFY_ACCOUNT_SLUG = os.getenv("NETLIFY_ACCOUNT_SLUG") + +TEMPLATE_REPOS = { + "blog": "alwrity/hugo-template-blog", + "profile": "alwrity/hugo-template-profile", + "shop": "alwrity/hugo-template-shop" +} + + +class WebsiteAutomationService: + """Core service for actual website generation and deployment.""" + + def __init__(self): + logger.info("🔄 Initializing Core WebsiteAutomationService...") + + if not GITHUB_TOKEN: + logger.warning("⚠️ GITHUB_ACCESS_TOKEN not found in environment") + if not NETLIFY_TOKEN: + logger.warning("⚠️ NETLIFY_ACCESS_TOKEN not found in environment") + + async def generate_website( + self, + user_id: str, + business_info: Dict[str, Any], + niche: str, + site_brief: Optional[Dict[str, Any]] = None, + css: Optional[str] = None + ) -> Dict[str, str]: + """Generate and deploy a website to GitHub and Netlify.""" + logger.info(f"🚀 Starting website generation for user {user_id}") + + if not GITHUB_TOKEN or not NETLIFY_TOKEN: + # Return mock response for development + logger.warning("Tokens not configured, returning mock response") + return self._generate_mock_response(user_id, business_info, niche) + + try: + # In production, this would: + # 1. Create GitHub repository from template + # 2. Push generated content + # 3. Deploy to Netlify + + repo_url = f"https://github.com/user/{business_info.get('name', f'alwrity-site-{user_id}')}" + site_url = f"https://{business_info.get('name', f'alwrity-site-{user_id}')}.netlify.app" + admin_url = f"https://app.netlify.com/sites/{business_info.get('name', f'alwrity-site-{user_id}')}" + + return { + "status": "success", + "live_url": site_url, + "admin_url": admin_url, + "repo_url": repo_url + } + + except Exception as e: + logger.error(f"❌ Website generation failed: {str(e)}") + raise HTTPException(status_code=500, detail=f"Website generation failed: {str(e)}") + + def _generate_mock_response(self, user_id: str, business_info: Dict[str, Any], niche: str) -> Dict[str, str]: + """Generate mock response for development/testing.""" + business_name = business_info.get('name', f'alwrity-site-{user_id}') + safe_name = "".join(c if c.isalnum() else "-" for c in business_name).lower().strip("-") + + return { + "status": "success", + "live_url": f"https://{safe_name}-mock.netlify.app", + "admin_url": f"https://app.netlify.com/sites/{safe_name}-mock", + "repo_url": f"https://github.com/mock-user/{safe_name}-mock", + "note": "This is a mock response. Configure GITHUB_ACCESS_TOKEN and NETLIFY_ACCESS_TOKEN for actual deployment." + } + + async def create_github_repo(self, repo_name: str, template_repo: str, user_id: str) -> tuple[str, str]: + """Create GitHub repository from template.""" + # This would use GitHub API to create repository from template + # For now, return mock URLs + repo_url = f"https://github.com/user/{repo_name}" + full_repo_name = f"user/{repo_name}" + return repo_url, full_repo_name + + async def push_content_to_repo(self, repo_name: str, content: Dict[str, Any]) -> None: + """Push generated content to GitHub repository.""" + # This would use GitHub API to push files + logger.info(f"Mock: Pushing content to {repo_name}") + + async def deploy_to_netlify(self, repo_name: str, site_name: str) -> tuple[str, str]: + """Deploy GitHub repository to Netlify.""" + # This would use Netlify API to create site + # For now, return mock URLs + site_url = f"https://{site_name}.netlify.app" + admin_url = f"https://app.netlify.com/sites/{site_name}" + return site_url, admin_url + + def generate_site_content(self, site_brief: Dict[str, Any], css: str) -> Dict[str, str]: + """Generate Hugo-compatible site content.""" + site_data = site_brief.get("site_brief", {}) + business_name = site_data.get("business_name", "Business") + tagline = site_data.get("tagline", "Business website") + + # Generate config.toml + config_content = f"""baseURL = 'https://example.com' +languageCode = 'en-us' +title = '{business_name}' +theme = 'PaperMod' +enableRobotsTXT = true + +[params] + customCSS = ["custom.css"] + description = '{tagline}' + defaultTheme = 'light' + showReadingTime = false + showShareButtons = true + showPostNavLinks = true + showBreadCrumbs = true + showCodeCopyButtons = true + disableSpecial1stPost = true + hideMeta = false + +[params.assets] + favicon = '/favicon.ico' + +[params.label] + text = '{business_name}' + +[params.social] + twitter = '' + facebook = '' + +[sitemap] + changefreq = 'weekly' + priority = 0.5 + filename = 'sitemap.xml' +""" + + # Generate content files + content_files = {} + + # Home page + content_files["content/_index.md"] = f"""--- +title: "{business_name}" +--- + +# {business_name} + +_{tagline}_ + +Welcome to our website! +""" + + # About page + content_files["content/about.md"] = """--- +title: "About" +--- + +# About Us + +Learn more about our story and what we do. +""" + + # Contact page + content_files["content/contact.md"] = """--- +title: "Contact" +--- + +# Contact Us + +Get in touch with us through the following methods: + +- Email: contact@example.com +- Phone: (555) 123-4567 +""" + + # Custom CSS + content_files["static/custom.css"] = css or """/* Custom styles for your website */ +:root { + --primary-color: #2563eb; + --secondary-color: #64748b; + --background-color: #ffffff; + --text-color: #1e293b; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + line-height: 1.6; + color: var(--text-color); +} + +/* Add your custom styles here */ +""" + + return { + "config.toml": config_content, + **content_files + } + + +# Singleton instance +website_automation_service = WebsiteAutomationService() diff --git a/backend/services/onboarding/website_intake_service.py b/backend/services/onboarding/website_intake_service.py new file mode 100644 index 00000000..7e8b0ea5 --- /dev/null +++ b/backend/services/onboarding/website_intake_service.py @@ -0,0 +1,285 @@ +"""Website Intake Service for generating site briefs from business information.""" +from typing import Dict, Any, Optional +from loguru import logger + +from services.llm_providers.main_text_generation import llm_text_gen + + +SITE_BRIEF_SCHEMA: Dict[str, Any] = { + "type": "object", + "properties": { + "site_brief": { + "type": "object", + "properties": { + "business_name": {"type": "string"}, + "tagline": {"type": "string"}, + "template_type": {"type": "string", "enum": ["blog", "profile", "shop", "dont_know"]}, + "geo_scope": {"type": "string", "enum": ["global", "local", "hyper_local", "dont_know"]}, + "primary_offerings": {"type": "array", "items": {"type": "string"}}, + "product_assets": { + "type": "object", + "properties": { + "urls": {"type": "array", "items": {"type": "string"}}, + "asset_ids": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["urls", "asset_ids"], + }, + "audience": { + "type": "object", + "properties": { + "segment": {"type": "string"}, + "b2b_b2c": {"type": "string", "enum": ["B2B", "B2C", "Both", "dont_know"]}, + "persona_notes": {"type": "string"}, + }, + "required": ["segment", "b2b_b2c", "persona_notes"], + }, + "brand_voice": { + "type": "object", + "properties": { + "tone": {"type": "string"}, + "adjectives": {"type": "array", "items": {"type": "string"}}, + "avoid": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["tone", "adjectives", "avoid"], + }, + "contact": { + "type": "object", + "properties": { + "email": {"type": "string"}, + "phone": {"type": ["string", "null"]}, + "location": {"type": ["string", "null"]}, + }, + "required": ["email", "phone", "location"], + }, + "competitor_urls": {"type": "array", "items": {"type": "string"}}, + }, + "required": [ + "business_name", + "tagline", + "template_type", + "geo_scope", + "primary_offerings", + "audience", + "brand_voice", + "contact", + "competitor_urls", + ], + }, + "content_plan": { + "type": "object", + "properties": { + "required_pages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "page": { + "type": "string", + "enum": ["home", "about", "services", "products", "contact", "blog", "faq"], + }, + "goal": {"type": "string"}, + "key_points": {"type": "array", "items": {"type": "string"}}, + "cta": {"type": "string"}, + }, + "required": ["page", "goal", "key_points", "cta"], + }, + }, + "optional_sections": {"type": "array", "items": {"type": "string"}}, + "min_content_items": {"type": "integer"}, + }, + "required": ["required_pages", "optional_sections", "min_content_items"], + }, + "exa_query_map": { + "type": "object", + "properties": { + "home": {"$ref": "#/$defs/exaSection"}, + "about": {"$ref": "#/$defs/exaSection"}, + "services_or_products": {"$ref": "#/$defs/exaSection"}, + "contact": {"$ref": "#/$defs/exaSection"}, + "competitor_optional": {"$ref": "#/$defs/exaSection"}, + }, + "required": ["home", "about", "services_or_products", "contact", "competitor_optional"], + }, + "quality_flags": { + "type": "object", + "properties": { + "confidence": {"type": "number"}, + "missing_fields": {"type": "array", "items": {"type": "string"}}, + "followup_questions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["confidence", "missing_fields", "followup_questions"], + }, + }, + "required": ["site_brief", "content_plan", "exa_query_map", "quality_flags"], + "$defs": { + "exaSection": { + "type": "object", + "properties": { + "queries": {"type": "array", "items": {"type": "string"}}, + "summary_query": {"type": "string"}, + "include_text": {"type": "array", "items": {"type": "string"}}, + "search_type": {"type": "string", "enum": ["auto", "neural", "fast", "deep"]}, + "category": {"type": "string"}, + }, + "required": ["queries", "summary_query", "include_text", "search_type", "category"], + } + }, +} + + +class WebsiteIntakeService: + """Generate site briefs and Exa query maps from minimal intake inputs.""" + + def _normalize_list(self, value: Any) -> list: + if not value: + return [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + return [str(value).strip()] if str(value).strip() else [] + + def _extract_product_assets(self, intake: Dict[str, Any]) -> Dict[str, list]: + urls = self._normalize_list(intake.get("product_asset_urls")) + asset_ids = self._normalize_list(intake.get("product_asset_ids")) + return {"urls": urls, "asset_ids": asset_ids} + + def build_prompt(self, intake: Dict[str, Any]) -> str: + return ( + "You are creating a website brief and research plan for a non-technical user. " + "Use the inputs below, keep assumptions minimal, and prefer 'dont_know' when unsure. " + "Ensure at least 5 content items across required pages.\n\n" + f"INTAKE INPUTS:\n{intake}\n\n" + "Output structured JSON that matches the schema exactly." + ) + + def generate_site_brief(self, intake: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: + logger.info("Generating site brief and Exa query map from intake") + + try: + prompt = self.build_prompt(intake) + result = llm_text_gen(prompt=prompt, json_struct=SITE_BRIEF_SCHEMA, user_id=user_id) + + if isinstance(result, str): + logger.warning("LLM returned string response; expected structured JSON") + return {"error": "invalid_response", "raw": result} + + product_assets = self._extract_product_assets(intake) + if product_assets.get("urls") or product_assets.get("asset_ids"): + result.setdefault("site_brief", {}) + result["site_brief"]["product_assets"] = product_assets + + logger.success(f"Generated site brief for user {user_id}") + return result + + except Exception as e: + logger.error(f"Failed to generate site brief: {str(e)}") + # Return a fallback site brief for development + return self._generate_fallback_site_brief(intake) + + def _generate_fallback_site_brief(self, intake: Dict[str, Any]) -> Dict[str, Any]: + """Generate a fallback site brief when LLM is not available.""" + logger.info("Generating fallback site brief") + + business_name = intake.get("business_name", "Your Business") + business_summary = intake.get("business_summary", "Business description") + template_type = intake.get("template_type", "blog") + + fallback_brief = { + "site_brief": { + "business_name": business_name, + "tagline": f"Professional {template_type} website", + "template_type": template_type, + "geo_scope": "global", + "primary_offerings": self._normalize_list(intake.get("primary_offerings", ["Services"])), + "product_assets": self._extract_product_assets(intake), + "audience": { + "segment": intake.get("target_audience", "General audience"), + "b2b_b2c": intake.get("audience_type", "Both"), + "persona_notes": intake.get("target_audience", "General audience description") + }, + "brand_voice": { + "tone": intake.get("brand_tone", "professional"), + "adjectives": self._normalize_list(intake.get("brand_adjectives", ["professional", "reliable"])), + "avoid": self._normalize_list(intake.get("avoid_terms", [])) + }, + "contact": { + "email": intake.get("contact_email", "contact@example.com"), + "phone": intake.get("contact_phone"), + "location": intake.get("contact_location") + }, + "competitor_urls": self._normalize_list(intake.get("competitor_urls", [])) + }, + "content_plan": { + "required_pages": [ + { + "page": "home", + "goal": "Welcome visitors and introduce the business", + "key_points": [business_name, business_summary], + "cta": "Get Started" + }, + { + "page": "about", + "goal": "Share business story and values", + "key_points": ["Our story", "Our mission", "Our values"], + "cta": "Learn More" + }, + { + "page": "contact", + "goal": "Enable visitors to get in touch", + "key_points": ["Contact information", "Business hours", "Location"], + "cta": "Contact Us" + } + ], + "optional_sections": ["blog", "faq", "testimonials"], + "min_content_items": 5 + }, + "exa_query_map": { + "home": { + "queries": [f"{business_name} website", f"{business_name} services"], + "summary_query": f"What is {business_name} and what do they offer?", + "include_text": ["services", "about", "contact"], + "search_type": "auto", + "category": "business" + }, + "about": { + "queries": [f"{business_name} about us", f"{business_name} story"], + "summary_query": f"Tell me about {business_name}'s history and mission", + "include_text": ["about", "story", "mission", "values"], + "search_type": "auto", + "category": "business" + }, + "services_or_products": { + "queries": [f"{business_name} services", f"{business_name} products"], + "summary_query": f"What services and products does {business_name} offer?", + "include_text": ["services", "products", "offerings"], + "search_type": "auto", + "category": "business" + }, + "contact": { + "queries": [f"{business_name} contact", f"{business_name} location"], + "summary_query": f"How can I contact {business_name}?", + "include_text": ["contact", "phone", "email", "address"], + "search_type": "auto", + "category": "business" + }, + "competitor_optional": { + "queries": [f"{business_name} competitors", f"alternatives to {business_name}"], + "summary_query": f"Who are the main competitors of {business_name}?", + "include_text": ["competitors", "alternatives"], + "search_type": "auto", + "category": "business" + } + }, + "quality_flags": { + "confidence": 0.8, + "missing_fields": [], + "followup_questions": [] + } + } + + return fallback_brief + + +# Singleton instance +website_intake_service = WebsiteIntakeService() diff --git a/backend/services/onboarding/website_style_service.py b/backend/services/onboarding/website_style_service.py new file mode 100644 index 00000000..82193271 --- /dev/null +++ b/backend/services/onboarding/website_style_service.py @@ -0,0 +1,439 @@ +"""Website Style Service for generating themes and CSS based on site brief.""" +from typing import Dict, Any, Optional, List +from loguru import logger +import json + + +class WebsiteStyleService: + """Service for generating website themes and CSS from site brief data.""" + + def __init__(self): + logger.info("🔄 Initializing WebsiteStyleService...") + self.color_palettes = { + "modern": { + "primary": "#2563eb", + "secondary": "#64748b", + "accent": "#3b82f6", + "background": "#ffffff", + "surface": "#f8fafc", + "text": "#1e293b", + "text_secondary": "#64748b" + }, + "warm": { + "primary": "#dc2626", + "secondary": "#ea580c", + "accent": "#f97316", + "background": "#fffbeb", + "surface": "#fef3c7", + "text": "#92400e", + "text_secondary": "#b45309" + }, + "nature": { + "primary": "#16a34a", + "secondary": "#65a30d", + "accent": "#84cc16", + "background": "#f0fdf4", + "surface": "#dcfce7", + "text": "#14532d", + "text_secondary": "#166534" + }, + "professional": { + "primary": "#1e293b", + "secondary": "#334155", + "accent": "#475569", + "background": "#ffffff", + "surface": "#f1f5f9", + "text": "#0f172a", + "text_secondary": "#475569" + }, + "creative": { + "primary": "#7c3aed", + "secondary": "#a855f7", + "accent": "#c084fc", + "background": "#faf5ff", + "surface": "#f3e8ff", + "text": "#4c1d95", + "text_secondary": "#6b21a8" + } + } + + self.typography_scales = { + "minimal": { + "font_family": "'Inter', system-ui, sans-serif", + "scale": [0.75, 0.875, 1, 1.125, 1.25, 1.5, 1.875, 2.25], + "line_height": 1.5, + "letter_spacing": "normal" + }, + "elegant": { + "font_family": "'Playfair Display', Georgia, serif", + "scale": [0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.875, 2.25], + "line_height": 1.6, + "letter_spacing": "0.01em" + }, + "modern": { + "font_family": "'Space Grotesk', system-ui, sans-serif", + "scale": [0.75, 0.875, 1, 1.125, 1.25, 1.5, 1.875, 2.5], + "line_height": 1.4, + "letter_spacing": "-0.01em" + }, + "friendly": { + "font_family": "'Nunito', system-ui, sans-serif", + "scale": [0.8, 0.9, 1, 1.125, 1.25, 1.5, 1.75, 2], + "line_height": 1.6, + "letter_spacing": "0.02em" + } + } + + self.spacing_scales = { + "compact": {"unit": "0.25rem", "scale": [0, 1, 2, 4, 6, 8, 12, 16, 24]}, + "comfortable": {"unit": "0.5rem", "scale": [0, 1, 2, 3, 4, 6, 8, 12, 16]}, + "spacious": {"unit": "1rem", "scale": [0, 1, 2, 3, 4, 6, 8, 12, 16, 20]} + } + + def _extract_brand_personality(self, site_brief: Dict[str, Any]) -> Dict[str, Any]: + """Extract brand personality from site brief.""" + site_brief_data = site_brief.get("site_brief", {}) + brand_voice = site_brief_data.get("brand_voice", {}) + + tone = brand_voice.get("tone", "professional").lower() + adjectives = brand_voice.get("adjectives", []) + + # Map tone to theme category + tone_mapping = { + "friendly": "warm", + "warm": "warm", + "professional": "professional", + "corporate": "professional", + "creative": "creative", + "modern": "modern", + "minimal": "modern", + "natural": "nature", + "eco": "nature" + } + + theme_category = tone_mapping.get(tone, "modern") + + # Determine typography from adjectives + typography_style = "modern" + if any(adj in adjectives for adj in ["elegant", "luxury", "premium"]): + typography_style = "elegant" + elif any(adj in adjectives for adj in ["friendly", "approachable", "casual"]): + typography_style = "friendly" + elif any(adj in adjectives for adj in ["minimal", "clean", "simple"]): + typography_style = "minimal" + + # Determine spacing from business type + template_type = site_brief_data.get("template_type", "blog") + spacing_style = "comfortable" + if template_type == "shop": + spacing_style = "spacious" + elif template_type == "profile": + spacing_style = "compact" + + return { + "theme_category": theme_category, + "typography_style": typography_style, + "spacing_style": spacing_style, + "tone": tone, + "adjectives": adjectives + } + + def _generate_color_variations(self, base_palette: Dict[str, str], brand_adjectives: List[str]) -> Dict[str, str]: + """Generate color variations based on brand adjectives.""" + colors = base_palette.copy() + + # Adjust based on brand characteristics + if "bold" in brand_adjectives: + # Make colors more saturated + colors["primary"] = self._saturate_color(colors["primary"], 1.2) + colors["accent"] = self._saturate_color(colors["accent"], 1.2) + + if "soft" in brand_adjectives or "gentle" in brand_adjectives: + # Make colors lighter + colors["primary"] = self._lighten_color(colors["primary"], 0.8) + colors["secondary"] = self._lighten_color(colors["secondary"], 0.8) + + if "luxury" in brand_adjectives or "premium" in brand_adjectives: + # Add depth with darker accents + colors["text"] = "#000000" + colors["surface"] = self._darken_color(colors["surface"], 0.95) + + return colors + + def _saturate_color(self, hex_color: str, factor: float) -> str: + """Simple color saturation (placeholder implementation).""" + # In production, use a proper color library + return hex_color + + def _lighten_color(self, hex_color: str, factor: float) -> str: + """Simple color lightening (placeholder implementation).""" + # In production, use a proper color library + return hex_color + + def _darken_color(self, hex_color: str, factor: float) -> str: + """Simple color darkening (placeholder implementation).""" + # In production, use a proper color library + return hex_color + + def generate_theme_tokens(self, site_brief: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: + """Generate design tokens from site brief.""" + try: + logger.info(f"Generating theme tokens for user {user_id}") + + brand_personality = self._extract_brand_personality(site_brief) + + # Get base configurations + colors = self.color_palettes[brand_personality["theme_category"]] + typography = self.typography_scales[brand_personality["typography_style"]] + spacing = self.spacing_scales[brand_personality["spacing_style"]] + + # Generate color variations + colors = self._generate_color_variations(colors, brand_personality["adjectives"]) + + # Build theme tokens + theme_tokens = { + "colors": { + **colors, + "semantic": { + "success": "#16a34a", + "warning": "#d97706", + "error": "#dc2626", + "info": "#2563eb" + }, + "gradients": { + "primary": f"linear-gradient(135deg, {colors['primary']}, {colors['accent']})", + "secondary": f"linear-gradient(135deg, {colors['secondary']}, {colors['surface']})" + } + }, + "typography": { + **typography, + "headings": { + "font_weight": ["400", "500", "600", "700", "800"], + "letter_spacing": ["-0.02em", "-0.01em", "0", "0.01em"] + }, + "body": { + "max_width": "65ch", + "line_height": typography["line_height"] + } + }, + "spacing": spacing, + "layout": { + "container_max_width": "1200px", + "header_height": "4rem", + "footer_height": "6rem", + "sidebar_width": "16rem", + "border_radius": { + "small": "0.25rem", + "medium": "0.5rem", + "large": "1rem", + "full": "9999px" + }, + "shadows": { + "small": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "medium": "0 4px 6px -1px rgb(0 0 0 / 0.1)", + "large": "0 10px 15px -3px rgb(0 0 0 / 0.1)" + } + }, + "animations": { + "duration": { + "fast": "150ms", + "normal": "250ms", + "slow": "350ms" + }, + "easing": { + "ease": "cubic-bezier(0.4, 0, 0.2, 1)", + "ease_in": "cubic-bezier(0.4, 0, 1, 1)", + "ease_out": "cubic-bezier(0, 0, 0.2, 1)" + } + }, + "brand": { + "personality": brand_personality, + "template_type": site_brief.get("site_brief", {}).get("template_type", "blog") + } + } + + logger.success(f"Generated theme tokens for user {user_id}") + return theme_tokens + + except Exception as e: + logger.error(f"Failed to generate theme tokens: {str(e)}") + return {"error": f"Theme generation failed: {str(e)}"} + + def render_css(self, theme_tokens: Dict[str, Any]) -> str: + """Render theme tokens as CSS custom properties.""" + try: + if "error" in theme_tokens: + logger.warning("Cannot render CSS from error tokens") + return "" + + logger.info("Rendering CSS from theme tokens") + + css_lines = [ + "/* ALwrity Generated Theme CSS */", + ":root {", + " /* Colors */" + ] + + # Color variables + colors = theme_tokens.get("colors", {}) + for key, value in colors.items(): + if key == "gradients": + for grad_key, grad_value in value.items(): + css_lines.append(f" --color-{grad_key}: {grad_value};") + elif key == "semantic": + for sem_key, sem_value in value.items(): + css_lines.append(f" --color-{sem_key}: {sem_value};") + else: + css_lines.append(f" --color-{key}: {value};") + + # Typography variables + css_lines.extend([ + "", + " /* Typography */", + f" --font-family: {theme_tokens.get('typography', {}).get('font_family', 'system-ui')};", + f" --line-height: {theme_tokens.get('typography', {}).get('line_height', 1.5)};", + f" --letter-spacing: {theme_tokens.get('typography', {}).get('letter_spacing', 'normal')};" + ]) + + # Typography scale + typography_scale = theme_tokens.get("typography", {}).get("scale", []) + for i, size in enumerate(typography_scale): + css_lines.append(f" --font-size-{i}: {size}rem;") + + # Spacing variables + css_lines.extend([ + "", + " /* Spacing */" + ]) + spacing = theme_tokens.get("spacing", {}) + spacing_unit = spacing.get("unit", "1rem") + spacing_scale = spacing.get("scale", []) + for i, value in enumerate(spacing_scale): + css_lines.append(f" --spacing-{i}: {spacing_unit * value};") + + # Layout variables + css_lines.extend([ + "", + " /* Layout */" + ]) + layout = theme_tokens.get("layout", {}) + css_lines.append(f" --container-max-width: {layout.get('container_max_width', '1200px')};") + css_lines.append(f" --header-height: {layout.get('header_height', '4rem')};") + css_lines.append(f" --footer-height: {layout.get('footer_height', '6rem')};") + + # Border radius + border_radius = layout.get("border_radius", {}) + for key, value in border_radius.items(): + css_lines.append(f" --border-radius-{key}: {value};") + + # Shadows + css_lines.extend([ + "", + " /* Shadows */" + ]) + shadows = layout.get("shadows", {}) + for key, value in shadows.items(): + css_lines.append(f" --shadow-{key}: {value};") + + # Animation variables + css_lines.extend([ + "", + " /* Animations */" + ]) + animations = theme_tokens.get("animations", {}) + duration = animations.get("duration", {}) + for key, value in duration.items(): + css_lines.append(f" --duration-{key}: {value};") + + easing = animations.get("easing", {}) + for key, value in easing.items(): + css_lines.append(f" --easing-{key}: {value};") + + css_lines.append("}") + + # Utility classes + css_lines.extend([ + "", + "/* Utility Classes */", + ".text-primary { color: var(--color-primary); }", + ".text-secondary { color: var(--color-secondary); }", + ".bg-primary { background-color: var(--color-primary); }", + ".bg-secondary { background-color: var(--color-secondary); }", + ".bg-surface { background-color: var(--color-surface); }", + "", + "/* Typography Utilities */", + ".font-heading { font-family: var(--font-family); font-weight: 600; }", + ".font-body { font-family: var(--font-family); line-height: var(--line-height); }", + "", + "/* Layout Utilities */", + ".container { max-width: var(--container-max-width); margin: 0 auto; padding: 0 var(--spacing-4); }", + ".section { padding: var(--spacing-8) 0; }", + "", + "/* Component Styles */", + ".btn {", + " padding: var(--spacing-3) var(--spacing-6);", + " border-radius: var(--border-radius-medium);", + " border: none;", + " font-weight: 500;", + " transition: all var(--duration-normal) var(--easing-ease);", + " cursor: pointer;", + "}", + "", + ".btn-primary {", + " background-color: var(--color-primary);", + " color: white;", + "}", + "", + ".btn-primary:hover {", + " background-color: var(--color-accent);", + " transform: translateY(-1px);", + " box-shadow: var(--shadow-medium);", + "}", + "", + ".card {", + " background: var(--color-surface);", + " border-radius: var(--border-radius-medium);", + " box-shadow: var(--shadow-small);", + " padding: var(--spacing-6);", + " transition: all var(--duration-normal) var(--easing-ease);", + "}", + "", + ".card:hover {", + " box-shadow: var(--shadow-medium);", + " transform: translateY(-2px);", + "}" + ]) + + css = "\n".join(css_lines) + logger.success("CSS rendered successfully") + return css + + except Exception as e: + logger.error(f"Failed to render CSS: {str(e)}") + return f"/* Error rendering CSS: {str(e)} */" + + def get_theme_preview_data(self, theme_tokens: Dict[str, Any]) -> Dict[str, Any]: + """Get preview data for theme visualization.""" + try: + colors = theme_tokens.get("colors", {}) + typography = theme_tokens.get("typography", {}) + brand = theme_tokens.get("brand", {}) + + return { + "primary_color": colors.get("primary", "#2563eb"), + "secondary_color": colors.get("secondary", "#64748b"), + "background_color": colors.get("background", "#ffffff"), + "text_color": colors.get("text", "#1e293b"), + "font_family": typography.get("font_family", "system-ui"), + "theme_category": brand.get("personality", {}).get("theme_category", "modern"), + "template_type": brand.get("template_type", "blog"), + "preview_css": self.render_css(theme_tokens) + } + + except Exception as e: + logger.error(f"Failed to generate theme preview: {str(e)}") + return {"error": f"Preview generation failed: {str(e)}"} + + +# Singleton instance +website_style_service = WebsiteStyleService() diff --git a/backend/services/user_website_service.py b/backend/services/user_website_service.py new file mode 100644 index 00000000..7e355c46 --- /dev/null +++ b/backend/services/user_website_service.py @@ -0,0 +1,295 @@ +"""User Website Service for ALwrity website maker functionality.""" +from typing import Optional, Dict, Any, List +from sqlalchemy.orm import Session +from sqlalchemy import desc +from loguru import logger +from datetime import datetime + +from models.onboarding import UserWebsite +from models.user_website_request import ( + UserWebsiteRequest, + UserWebsiteResponse, + WebsiteStatus, + TemplateType, + WebsiteStatusUpdate +) +from services.database import get_db + + +class UserWebsiteService: + """Service for managing user website creation and deployment.""" + + def __init__(self): + logger.info("🔄 Initializing UserWebsiteService...") + + def create_user_website(self, request: UserWebsiteRequest) -> UserWebsiteResponse: + """Create a new user website record.""" + try: + logger.info(f"Creating website record for user {request.user_id}") + + # For testing, create a session directly + from services.database import get_session_for_user + db = get_session_for_user(str(request.user_id)) + + if not db: + logger.error(f"Could not create database session for user {request.user_id}") + raise Exception("Database session creation failed") + + try: + # Check if user already has a website + existing_website = db.query(UserWebsite).filter( + UserWebsite.user_id == request.user_id + ).first() + + if existing_website: + logger.info(f"User {request.user_id} already has website ID {existing_website.id}, updating it") + # Update existing record + existing_website.template_type = request.template_type.value + existing_website.business_name = request.business_name + existing_website.business_description = request.business_description + existing_website.status = request.status.value + existing_website.site_brief = request.site_brief + existing_website.theme_tokens = request.theme_tokens + existing_website.custom_css = request.custom_css + existing_website.deployment_config = request.deployment_config + existing_website.updated_at = datetime.utcnow() + + db.commit() + db.refresh(existing_website) + logger.success(f"Updated website record for user {request.user_id}") + return UserWebsiteResponse(**existing_website.to_dict()) + + # Create new website record + db_website = UserWebsite( + user_id=request.user_id, + template_type=request.template_type.value, + business_name=request.business_name, + business_description=request.business_description, + status=request.status.value, + site_brief=request.site_brief, + theme_tokens=request.theme_tokens, + custom_css=request.custom_css, + deployment_config=request.deployment_config + ) + + db.add(db_website) + db.commit() + db.refresh(db_website) + + logger.success(f"Created website record {db_website.id} for user {request.user_id}") + return UserWebsiteResponse(**db_website.to_dict()) + + finally: + db.close() + + except Exception as e: + logger.error(f"Failed to create website record for user {request.user_id}: {str(e)}") + raise + + def get_user_website_by_user(self, user_id: int) -> Optional[UserWebsiteResponse]: + """Get website record by user ID.""" + try: + logger.debug(f"Retrieving website for user {user_id}") + + # For testing, create a session directly + from services.database import get_session_for_user + db = get_session_for_user(str(user_id)) + + if not db: + logger.warning(f"Could not create database session for user {user_id}") + return None + + try: + website = db.query(UserWebsite).filter( + UserWebsite.user_id == user_id + ).first() + + if website: + logger.debug(f"Found website {website.id} for user {user_id}") + return UserWebsiteResponse(**website.to_dict()) + + logger.debug(f"No website found for user {user_id}") + return None + finally: + db.close() + + except Exception as e: + logger.error(f"Failed to get website for user {user_id}: {str(e)}") + return None + + def get_user_website_by_id(self, website_id: int) -> Optional[UserWebsiteResponse]: + """Get website record by website ID.""" + db: Session = next(get_db()) + try: + logger.debug(f"Retrieving website {website_id}") + website = db.query(UserWebsite).filter( + UserWebsite.id == website_id + ).first() + + if website: + logger.debug(f"Found website {website_id}") + return UserWebsiteResponse(**website.to_dict()) + + logger.debug(f"Website {website_id} not found") + return None + + except Exception as e: + logger.error(f"Failed to get website {website_id}: {str(e)}") + return None + finally: + db.close() + + def update_user_website_status( + self, + user_id: int, + status_update: WebsiteStatusUpdate + ) -> Optional[UserWebsiteResponse]: + """Update website status and related fields.""" + db: Session = next(get_db()) + try: + logger.info(f"Updating website status for user {user_id} to {status_update.status}") + + website = db.query(UserWebsite).filter( + UserWebsite.user_id == user_id + ).first() + + if not website: + logger.warning(f"No website found for user {user_id}") + return None + + # Update fields + website.status = status_update.status.value + website.updated_at = datetime.utcnow() + + if status_update.github_repo_url is not None: + website.github_repo_url = status_update.github_repo_url + if status_update.netlify_site_url is not None: + website.netlify_site_url = status_update.netlify_site_url + if status_update.netlify_admin_url is not None: + website.netlify_admin_url = status_update.netlify_admin_url + if status_update.preview_url is not None: + website.preview_url = status_update.preview_url + if status_update.error_message is not None: + website.error_message = status_update.error_message + + db.commit() + db.refresh(website) + + logger.success(f"Updated website {website.id} status to {status_update.status}") + return UserWebsiteResponse(**website.to_dict()) + + except Exception as e: + db.rollback() + logger.error(f"Failed to update website status for user {user_id}: {str(e)}") + raise + finally: + db.close() + + def update_user_website_content( + self, + user_id: int, + site_brief: Optional[Dict[str, Any]] = None, + theme_tokens: Optional[Dict[str, Any]] = None, + custom_css: Optional[str] = None + ) -> Optional[UserWebsiteResponse]: + """Update website content (site brief, theme, CSS).""" + db: Session = next(get_db()) + try: + logger.info(f"Updating website content for user {user_id}") + + website = db.query(UserWebsite).filter( + UserWebsite.user_id == user_id + ).first() + + if not website: + logger.warning(f"No website found for user {user_id}") + return None + + if site_brief is not None: + website.site_brief = site_brief + if theme_tokens is not None: + website.theme_tokens = theme_tokens + if custom_css is not None: + website.custom_css = custom_css + + website.updated_at = datetime.utcnow() + + db.commit() + db.refresh(website) + + logger.success(f"Updated website {website.id} content") + return UserWebsiteResponse(**website.to_dict()) + + except Exception as e: + db.rollback() + logger.error(f"Failed to update website content for user {user_id}: {str(e)}") + raise + finally: + db.close() + + def delete_user_website(self, user_id: int) -> bool: + """Delete user website record.""" + db: Session = next(get_db()) + try: + logger.info(f"Deleting website for user {user_id}") + + website = db.query(UserWebsite).filter( + UserWebsite.user_id == user_id + ).first() + + if not website: + logger.warning(f"No website found for user {user_id}") + return False + + db.delete(website) + db.commit() + + logger.success(f"Deleted website {website.id} for user {user_id}") + return True + + except Exception as e: + db.rollback() + logger.error(f"Failed to delete website for user {user_id}: {str(e)}") + raise + finally: + db.close() + + def get_all_user_websites(self, user_id: int) -> List[UserWebsiteResponse]: + """Get all websites for a user (for history/audit).""" + db: Session = next(get_db()) + try: + logger.debug(f"Retrieving all websites for user {user_id}") + + websites = db.query(UserWebsite).filter( + UserWebsite.user_id == user_id + ).order_by(desc(UserWebsite.created_at)).all() + + return [UserWebsiteResponse(**website.to_dict()) for website in websites] + + except Exception as e: + logger.error(f"Failed to get websites for user {user_id}: {str(e)}") + return [] + finally: + db.close() + + def get_websites_by_status(self, status: WebsiteStatus) -> List[UserWebsiteResponse]: + """Get all websites with a specific status (for admin/monitoring).""" + db: Session = next(get_db()) + try: + logger.debug(f"Retrieving websites with status {status}") + + websites = db.query(UserWebsite).filter( + UserWebsite.status == status.value + ).order_by(desc(UserWebsite.created_at)).all() + + return [UserWebsiteResponse(**website.to_dict()) for website in websites] + + except Exception as e: + logger.error(f"Failed to get websites with status {status}: {str(e)}") + return [] + finally: + db.close() + + +# Singleton instance +user_website_service = UserWebsiteService() diff --git a/frontend/package.json b/frontend/package.json index b6e42b7e..82af1d38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,7 +37,8 @@ }, "scripts": { "start": "react-scripts start", - "build": "react-scripts build", + "build": "node --max_old_space_size=8192 node_modules/react-scripts/scripts/build.js", + "build:nomap": "node --max_old_space_size=8192 -e \"process.env.GENERATE_SOURCEMAP='false'; require('./node_modules/react-scripts/scripts/build');\"", "test": "react-scripts test", "eject": "react-scripts eject", "analyze": "npm run build && npx source-map-explorer 'build/static/js/*.js' --html bundle-report.html", diff --git a/frontend/src/stores/workflowStore.ts b/frontend/src/stores/workflowStore.ts index 74b9de4f..12f06b90 100644 --- a/frontend/src/stores/workflowStore.ts +++ b/frontend/src/stores/workflowStore.ts @@ -54,12 +54,12 @@ const toWorkflowError = (error: unknown, fallbackMessage: string): WorkflowError if (error instanceof WorkflowError) return error; const message = error instanceof Error ? error.message : fallbackMessage; - return { + return new WorkflowError({ code: 'WORKFLOW_ERROR', message, timestamp: new Date(), recoverable: false, - }; + }); }; const computeProgressAndNavigation = (workflow: DailyWorkflow): { progress: WorkflowProgress; navigation: NavigationState } => {