From 277d27419b4cc4fe111c22318ed9b32636c7bbbc Mon Sep 17 00:00:00 2001 From: Saahi30 Date: Wed, 5 Nov 2025 23:07:59 +0530 Subject: [PATCH 01/13] backend(auth): implement atomic signup with Supabase admin API and rollback --- backend/app/api/routes/auth.py | 100 ++++++++++++++++----------- backend/app/core/supabase_clients.py | 14 ++++ 2 files changed, 72 insertions(+), 42 deletions(-) create mode 100644 backend/app/core/supabase_clients.py diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index a791914..9db01dc 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,14 +1,11 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, EmailStr, Field from gotrue.errors import AuthApiError +from typing import Optional -from supabase import create_client, Client -from app.core.config import settings +from app.core.supabase_clients import supabase_anon, supabase_admin router = APIRouter() -# Use anon key for end-user auth flows and service role for admin ops -supabase_public: Client = create_client(settings.supabase_url, settings.supabase_key) -supabase_admin: Client = create_client(settings.supabase_url, settings.supabase_service_key) class SignupRequest(BaseModel): @@ -26,59 +23,77 @@ class SignupResponse(BaseModel): Response schema for user signup. """ message: str - user_id: str | None = None + user_id: Optional[str] = None @router.post("/api/auth/signup", response_model=SignupResponse) async def signup_user(payload: SignupRequest): - """ - Atomic signup: creates Supabase Auth user and profile row together. - Supabase sends verification email automatically. If profile creation fails, the code attempts to delete the created auth user via supabase_admin (rollback is implemented). + Atomic signup using Supabase Admin API: + 1. Create auth user via admin.create_user() + 2. Insert profile row with id = created user id + 3. If profile insert fails -> delete auth user (rollback) """ try: - # 1. Create user via Supabase Auth (sends verification email automatically) + # 1. Create auth user using admin API (atomic) try: - auth_resp = supabase_public.auth.sign_up({ + create_res = supabase_admin.auth.admin.create_user({ "email": payload.email, "password": payload.password, + # Don't auto-confirm for production (requires email verification) + "email_confirm": False }) except AuthApiError as e: status = 409 if getattr(e, "code", None) == "user_already_exists" else getattr(e, "status", 400) or 400 raise HTTPException(status_code=status, detail=str(e)) from e - user = getattr(auth_resp, "user", None) - if not user or not getattr(user, "id", None): - error_msg = getattr(auth_resp, "error", None) - raise HTTPException(status_code=400, detail=f"Failed to create auth user. {error_msg}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Admin create_user failed: {str(e)}") from e + + # Handle different response shapes from supabase-py + user = None + if hasattr(create_res, "user"): + user = create_res.user + elif hasattr(create_res, "data") and create_res.data: + if hasattr(create_res.data, "user"): + user = create_res.data.user + elif isinstance(create_res.data, dict) and "user" in create_res.data: + user = create_res.data["user"] + elif isinstance(create_res, dict): + user = create_res.get("user") or create_res.get("data", {}).get("user") + + if not user: + raise HTTPException(status_code=500, detail="Failed to create auth user (admin API).") + + user_id = getattr(user, "id", None) or (user.get("id") if hasattr(user, "get") else None) + if not user_id: + raise HTTPException(status_code=500, detail="Auth user created but no id returned.") # 2. Insert profile row profile = { - "id": user.id, + "id": user_id, "name": payload.name, "role": payload.role } res = supabase_admin.table("profiles").insert(profile).execute() - if not res.data: - # 3. Rollback: delete auth user if profile insert fails - rollback_error = None - for attempt in range(2): # try up to 2 times - try: - supabase_admin.auth.admin.delete_user(user.id) - break - except Exception as rollback_err: - rollback_error = rollback_err - if attempt == 0: - continue # retry once - if rollback_error: - # Log the orphaned user for manual cleanup - # logger.error(f"Failed to rollback user {user.id}: {rollback_error}") + + # 3. If profile insert failed -> rollback by deleting the auth user + insert_data = getattr(res, "data", None) + if not insert_data: + # Try to rollback the created auth user + try: + supabase_admin.auth.admin.delete_user(user_id) + except Exception as rollback_err: + # If rollback delete fails, log details and return 500 with clear instructions raise HTTPException( status_code=500, - detail=f"Failed to create profile and rollback failed. User {user.id} may be orphaned. Error: {rollback_error}" - ) from rollback_error - raise HTTPException(status_code=500, detail="Failed to create profile. User rolled back.") + detail=f"Profile insert failed and rollback deletion failed for user {user_id}: {rollback_err}" + ) from rollback_err + raise HTTPException(status_code=500, detail="Failed to create profile. Auth user removed for safety.") - return SignupResponse(message="Signup successful! Please check your inbox to verify your email.", user_id=user.id) + return SignupResponse( + message="Signup successful! Please check your inbox to verify your email.", + user_id=user_id + ) except HTTPException: raise except Exception as e: @@ -97,10 +112,11 @@ class LoginResponse(BaseModel): Response schema for user login. """ message: str - user_id: str | None = None - email: str | None = None - role: str | None = None - name: str | None = None + user_id: Optional[str] = None + email: Optional[str] = None + role: Optional[str] = None + name: Optional[str] = None + onboarding_completed: bool = False @router.post("/api/auth/login", response_model=LoginResponse) async def login_user(payload: LoginRequest): @@ -112,14 +128,13 @@ async def login_user(payload: LoginRequest): try: # 1. Authenticate user try: - auth_resp = supabase_public.auth.sign_in_with_password({ + auth_resp = supabase_anon.auth.sign_in_with_password({ "email": payload.email, "password": payload.password }) user = getattr(auth_resp, "user", None) except Exception as e: # Supabase Python SDK v2 raises exceptions for auth errors - # Import AuthApiError if available if hasattr(e, "code") and e.code == "email_not_confirmed": raise HTTPException(status_code=403, detail="Please verify your email before logging in.") raise HTTPException(status_code=401, detail=str(e)) @@ -127,7 +142,7 @@ async def login_user(payload: LoginRequest): raise HTTPException(status_code=401, detail="Invalid credentials.") # 2. Fetch user profile - profile_res = supabase_admin.table("profiles").select("id, name, role").eq("id", user.id).single().execute() + profile_res = supabase_admin.table("profiles").select("id, name, role, onboarding_completed").eq("id", user.id).single().execute() profile = profile_res.data if hasattr(profile_res, "data") else None if not profile: raise HTTPException(status_code=404, detail="User profile not found.") @@ -137,7 +152,8 @@ async def login_user(payload: LoginRequest): user_id=user.id, email=user.email, role=profile.get("role"), - name=profile.get("name") + name=profile.get("name"), + onboarding_completed=profile.get("onboarding_completed", False) ) except HTTPException: raise diff --git a/backend/app/core/supabase_clients.py b/backend/app/core/supabase_clients.py new file mode 100644 index 0000000..4e84c40 --- /dev/null +++ b/backend/app/core/supabase_clients.py @@ -0,0 +1,14 @@ +""" +Supabase client instances for different use cases: +- supabase_anon: For user-facing operations (anon key) +- supabase_admin: For server-side atomic operations (service role) +""" + +from supabase import create_client +from app.core.config import settings + +# Client for user-facing operations (anon key) +supabase_anon = create_client(settings.supabase_url, settings.supabase_key) + +# Admin client for server-side atomic operations (service role) +supabase_admin = create_client(settings.supabase_url, settings.supabase_service_key) From 6c243e8cca84bf8083fa5cf522fb32cc8c248dfd Mon Sep 17 00:00:00 2001 From: Saahi30 Date: Wed, 5 Nov 2025 23:20:19 +0530 Subject: [PATCH 02/13] feat(onboarding): add onboarding flows for brand and creator --- frontend/app/brand/onboarding/page.tsx | 987 ++++++++++++++++++ frontend/app/creator/onboarding/page.tsx | 738 +++++++++++++ .../components/onboarding/ImageUpload.tsx | 171 +++ .../components/onboarding/MultiSelect.tsx | 101 ++ .../components/onboarding/ProgressBar.tsx | 28 + .../onboarding/TypeformQuestion.tsx | 123 +++ 6 files changed, 2148 insertions(+) create mode 100644 frontend/app/brand/onboarding/page.tsx create mode 100644 frontend/app/creator/onboarding/page.tsx create mode 100644 frontend/components/onboarding/ImageUpload.tsx create mode 100644 frontend/components/onboarding/MultiSelect.tsx create mode 100644 frontend/components/onboarding/ProgressBar.tsx create mode 100644 frontend/components/onboarding/TypeformQuestion.tsx diff --git a/frontend/app/brand/onboarding/page.tsx b/frontend/app/brand/onboarding/page.tsx new file mode 100644 index 0000000..8933313 --- /dev/null +++ b/frontend/app/brand/onboarding/page.tsx @@ -0,0 +1,987 @@ +"use client"; + +import ImageUpload from "@/components/onboarding/ImageUpload"; +import MultiSelect from "@/components/onboarding/MultiSelect"; +import ProgressBar from "@/components/onboarding/ProgressBar"; +import TypeformQuestion from "@/components/onboarding/TypeformQuestion"; +import { uploadBrandLogo } from "@/lib/storage-helpers"; +import { supabase } from "@/lib/supabaseClient"; +import { AnimatePresence } from "framer-motion"; +import { CheckCircle2, Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +const TOTAL_STEPS = 11; + +// Industry options +const INDUSTRIES = [ + "Technology & Software", + "Fashion & Apparel", + "Beauty & Cosmetics", + "Food & Beverage", + "Health & Wellness", + "Gaming & Esports", + "Travel & Hospitality", + "E-commerce & Retail", + "Financial Services", + "Education & E-learning", + "Entertainment & Media", + "Sports & Fitness", + "Home & Lifestyle", + "Automotive", + "B2B Services", + "Other", +]; + +// Company sizes +const COMPANY_SIZES = [ + "Startup (1-10)", + "Small (11-50)", + "Medium (51-200)", + "Large (201-1000)", + "Enterprise (1000+)", +]; + +// Age groups +const AGE_GROUPS = ["13-17", "18-24", "25-34", "35-44", "45-54", "55+"]; + +// Genders +const GENDERS = ["Male", "Female", "Non-binary", "All"]; + +// Locations +const LOCATIONS = [ + "United States", + "India", + "United Kingdom", + "Canada", + "Australia", + "Europe", + "Asia", + "Global", + "Other", +]; + +// Interests/Niches +const INTERESTS = [ + "Gaming", + "Technology", + "Fashion", + "Beauty", + "Fitness", + "Food", + "Travel", + "Lifestyle", + "Education", + "Entertainment", + "Business", + "Arts", + "Music", + "Sports", + "Parenting", + "Home", +]; + +// Brand values +const BRAND_VALUES = [ + "Sustainability", + "Innovation", + "Quality", + "Affordability", + "Inclusivity", + "Authenticity", + "Transparency", + "Social Responsibility", + "Customer Focus", + "Excellence", +]; + +// Brand personality +const BRAND_PERSONALITIES = [ + "Professional", + "Fun", + "Edgy", + "Friendly", + "Luxurious", + "Casual", + "Bold", + "Sophisticated", + "Playful", + "Trustworthy", +]; + +// Marketing goals +const MARKETING_GOALS = [ + "Brand Awareness", + "Product Sales", + "Lead Generation", + "Social Engagement", + "Content Creation", + "Community Building", + "Market Research", + "Customer Retention", +]; + +// Budget ranges +const BUDGET_RANGES = [ + "Less than $5K", + "$5K - $20K", + "$20K - $50K", + "$50K - $100K", + "$100K+", +]; + +const CAMPAIGN_BUDGET_RANGES = [ + "Less than $1K", + "$1K - $5K", + "$5K - $10K", + "$10K - $25K", + "$25K+", +]; + +// Campaign types +const CAMPAIGN_TYPES = [ + "Sponsored Posts", + "Product Reviews", + "Brand Ambassadorships", + "Event Coverage", + "User Generated Content", + "Influencer Takeovers", +]; + +// Preferred creator sizes +const PREFERRED_CREATOR_SIZES = [ + "Nano (1K-10K)", + "Micro (10K-100K)", + "Mid-tier (100K-500K)", + "Macro (500K-1M)", + "Mega (1M+)", +]; + +interface BrandFormData { + companyName: string; + companyTagline: string; + industry: string; + description: string; + websiteUrl: string; + companySize: string; + targetAgeGroups: string[]; + targetGenders: string[]; + targetLocations: string[]; + targetInterests: string[]; + brandValues: string[]; + brandPersonality: string[]; + marketingGoals: string[]; + monthlyBudget: string; + budgetPerCampaignMin: string; + budgetPerCampaignMax: string; + campaignTypes: string[]; + preferredNiches: string[]; + preferredCreatorSize: string[]; + minFollowers: string; + companyLogo: File | null; +} + +export default function BrandOnboardingPage() { + const router = useRouter(); + const [currentStep, setCurrentStep] = useState(1); + const [isSubmitting, setIsSubmitting] = useState(false); + const [userId, setUserId] = useState(null); + + const [formData, setFormData] = useState({ + companyName: "", + companyTagline: "", + industry: "", + description: "", + websiteUrl: "", + companySize: "", + targetAgeGroups: [], + targetGenders: [], + targetLocations: [], + targetInterests: [], + brandValues: [], + brandPersonality: [], + marketingGoals: [], + monthlyBudget: "", + budgetPerCampaignMin: "", + budgetPerCampaignMax: "", + campaignTypes: [], + preferredNiches: [], + preferredCreatorSize: [], + minFollowers: "", + companyLogo: null, + }); + + // Get user ID on mount + useEffect(() => { + const getUser = async () => { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + router.push("/login"); + return; + } + setUserId(user.id); + }; + getUser(); + }, [router]); + + const handleNext = () => { + if (currentStep < TOTAL_STEPS) { + setCurrentStep(currentStep + 1); + } + }; + + const handleBack = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + } + }; + + const updateFormData = (field: keyof BrandFormData, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleComplete = async () => { + if (!userId) return; + + setIsSubmitting(true); + + try { + // Upload company logo if provided + let logoUrl = null; + if (formData.companyLogo) { + logoUrl = await uploadBrandLogo(formData.companyLogo, userId); + } + + // Insert brand profile with correct column names + const { error: brandError } = await supabase.from("brands").insert({ + user_id: userId, + company_name: formData.companyName, + company_tagline: formData.companyTagline, + industry: formData.industry, + company_description: formData.description, + website_url: formData.websiteUrl, + company_size: formData.companySize, + company_logo_url: logoUrl, + target_audience_age_groups: formData.targetAgeGroups, + target_audience_gender: formData.targetGenders, + target_audience_locations: formData.targetLocations, + target_audience_interests: formData.targetInterests, + brand_values: formData.brandValues, + brand_personality: formData.brandPersonality, + marketing_goals: formData.marketingGoals, + monthly_marketing_budget: formData.monthlyBudget + ? Number(formData.monthlyBudget) + : null, + budget_per_campaign_min: formData.budgetPerCampaignMin + ? Number(formData.budgetPerCampaignMin) + : null, + budget_per_campaign_max: formData.budgetPerCampaignMax + ? Number(formData.budgetPerCampaignMax) + : null, + campaign_types_interested: formData.campaignTypes, + preferred_creator_niches: formData.preferredNiches, + preferred_creator_size: formData.preferredCreatorSize, + minimum_followers_required: formData.minFollowers + ? parseInt(formData.minFollowers) + : null, + }); + + if (brandError) throw brandError; + + // Mark onboarding as complete + const { error: profileError } = await supabase + .from("profiles") + .update({ onboarding_completed: true }) + .eq("id", userId); + + if (profileError) throw profileError; + + // Show success and redirect + setTimeout(() => { + router.push("/brand/home"); + }, 2000); + } catch (error: any) { + console.error("Onboarding error:", error); + alert("Failed to complete onboarding. Please try again."); + setIsSubmitting(false); + } + }; + + // Validation for each step + const canGoNext = () => { + switch (currentStep) { + case 1: + return true; // Welcome screen + case 2: + return formData.companyName.trim().length >= 2; + case 3: + return formData.industry !== ""; + case 4: + return ( + formData.description.trim().length >= 50 && + formData.websiteUrl.trim().length > 0 && + formData.companySize !== "" + ); + case 5: + return ( + formData.targetAgeGroups.length > 0 && + formData.targetGenders.length > 0 && + formData.targetLocations.length > 0 + ); + case 6: + return ( + formData.brandValues.length > 0 && + formData.brandPersonality.length > 0 + ); + case 7: + return formData.marketingGoals.length > 0; + case 8: + return ( + formData.monthlyBudget !== "" && + formData.budgetPerCampaignMin !== "" && + formData.budgetPerCampaignMax !== "" && + formData.campaignTypes.length > 0 + ); + case 9: + return ( + formData.preferredNiches.length > 0 && + formData.preferredCreatorSize.length > 0 + ); + case 10: + return true; // Logo is optional + case 11: + return true; // Review step + default: + return false; + } + }; + + if (!userId) { + return ( +
+ +
+ ); + } + + return ( + <> + + + {/* Step 1: Welcome */} + {currentStep === 1 && ( + +
+

+ We'll help you create a profile to connect with the perfect + creators for your brand. Ready? +

+
+
+ )} + + {/* Step 2: Company Basics */} + {currentStep === 2 && ( + +
+
+ + + updateFormData("companyName", e.target.value) + } + placeholder="Your company name" + className="w-full rounded-lg border-2 border-gray-300 px-6 py-4 text-lg transition-colors focus:border-purple-500 focus:outline-none" + autoFocus + /> +
+
+ + + updateFormData("companyTagline", e.target.value) + } + placeholder="A short, memorable tagline" + className="w-full rounded-lg border-2 border-gray-300 px-4 py-3 transition-colors focus:border-purple-500 focus:outline-none" + /> +
+
+
+ )} + + {/* Step 3: Industry */} + {currentStep === 3 && ( + + + + )} + + {/* Step 4: Company Description */} + {currentStep === 4 && ( + +
+
+ +