diff --git a/backend/SQL b/backend/SQL index d437e5c..c272920 100644 --- a/backend/SQL +++ b/backend/SQL @@ -1,8 +1,341 @@ --- Table for user profiles -create table if not exists profiles ( - id uuid references auth.users(id) on delete cascade, - name text not null, - role text check (role in ('Creator', 'Brand')) not null, - created_at timestamp with time zone default timezone('utc', now()), - primary key (id) +-- Define custom ENUM types (required by several tables below) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'application_status') THEN + CREATE TYPE application_status AS ENUM ('applied', 'reviewing', 'accepted', 'rejected'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'invite_status') THEN + CREATE TYPE invite_status AS ENUM ('pending', 'accepted', 'declined', 'expired'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'payment_status') THEN + CREATE TYPE payment_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'refunded'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'deal_status') THEN + CREATE TYPE deal_status AS ENUM ('draft', 'proposed', 'negotiating', 'active', 'completed', 'cancelled'); + END IF; +END $$; + + +-- This file is no longer maintained here. +-- For the latest schema reference and documentation, see: docs/database/schema-reference.md + +CREATE TABLE public.brands ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + user_id uuid NOT NULL UNIQUE, + company_name text NOT NULL, + company_tagline text, + company_description text, + company_logo_url text, + company_cover_image_url text, + industry text NOT NULL, + sub_industry text[] DEFAULT ARRAY[]::text[], + company_size text, + founded_year integer, + headquarters_location text, + company_type text, + website_url text NOT NULL, + contact_email text, + contact_phone text, + social_media_links jsonb, + target_audience_age_groups text[] DEFAULT ARRAY[]::text[], + target_audience_gender text[] DEFAULT ARRAY[]::text[], + target_audience_locations text[] DEFAULT ARRAY[]::text[], + target_audience_interests text[] DEFAULT ARRAY[]::text[], + target_audience_income_level text[] DEFAULT ARRAY[]::text[], + target_audience_description text, + brand_values text[] DEFAULT ARRAY[]::text[], + brand_personality text[] DEFAULT ARRAY[]::text[], + brand_voice text, + brand_colors jsonb, + marketing_goals text[] DEFAULT ARRAY[]::text[], + campaign_types_interested text[] DEFAULT ARRAY[]::text[], + preferred_content_types text[] DEFAULT ARRAY[]::text[], + preferred_platforms text[] DEFAULT ARRAY[]::text[], + campaign_frequency text, + monthly_marketing_budget numeric, + influencer_budget_percentage double precision, + budget_per_campaign_min numeric, + budget_per_campaign_max numeric, + typical_deal_size numeric, + payment_terms text, + offers_product_only_deals boolean DEFAULT false, + offers_affiliate_programs boolean DEFAULT false, + affiliate_commission_rate double precision, + preferred_creator_niches text[] DEFAULT ARRAY[]::text[], + preferred_creator_size text[] DEFAULT ARRAY[]::text[], + preferred_creator_locations text[] DEFAULT ARRAY[]::text[], + minimum_followers_required integer, + minimum_engagement_rate double precision, + content_dos text[] DEFAULT ARRAY[]::text[], + content_donts text[] DEFAULT ARRAY[]::text[], + brand_safety_requirements text[] DEFAULT ARRAY[]::text[], + competitor_brands text[] DEFAULT ARRAY[]::text[], + exclusivity_required boolean DEFAULT false, + exclusivity_duration_months integer, + past_campaigns_count integer DEFAULT 0, + successful_partnerships text[] DEFAULT ARRAY[]::text[], + case_studies text[] DEFAULT ARRAY[]::text[], + average_campaign_roi double precision, + products_services text[] DEFAULT ARRAY[]::text[], + product_price_range text, + product_categories text[] DEFAULT ARRAY[]::text[], + seasonal_products boolean DEFAULT false, + product_catalog_url text, + business_verified boolean DEFAULT false, + payment_verified boolean DEFAULT false, + tax_id_verified boolean DEFAULT false, + profile_completion_percentage integer DEFAULT 0, + is_active boolean DEFAULT true, + is_featured boolean DEFAULT false, + is_verified_brand boolean DEFAULT false, + subscription_tier text DEFAULT 'free'::text, + featured_until timestamp with time zone, + ai_profile_summary text, + search_keywords text[] DEFAULT ARRAY[]::text[], + matching_score_base double precision DEFAULT 50.0, + total_deals_posted integer DEFAULT 0, + total_deals_completed integer DEFAULT 0, + total_spent numeric DEFAULT 0, + average_deal_rating double precision, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now(), + last_active_at timestamp with time zone DEFAULT now(), + CONSTRAINT brands_pkey PRIMARY KEY (id), + CONSTRAINT brands_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.profiles(id) ); +CREATE TABLE public.campaign_applications ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + campaign_id uuid NOT NULL, + creator_id uuid NOT NULL, + profile_snapshot jsonb DEFAULT '{}'::jsonb, + message text, + proposed_amount numeric, + attachments jsonb DEFAULT '[]'::jsonb, + status USER-DEFINED DEFAULT 'applied'::application_status, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now(), + CONSTRAINT campaign_applications_pkey PRIMARY KEY (id), + CONSTRAINT campaign_applications_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES public.campaigns(id), + CONSTRAINT campaign_applications_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.creators(id) +); +CREATE TABLE public.campaign_assets ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + campaign_id uuid, + deal_id uuid, + uploaded_by uuid, + url text NOT NULL, + type text, + meta jsonb DEFAULT '{}'::jsonb, + created_at timestamp with time zone DEFAULT now(), + CONSTRAINT campaign_assets_pkey PRIMARY KEY (id), + CONSTRAINT campaign_assets_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES public.campaigns(id), + CONSTRAINT campaign_assets_deal_id_fkey FOREIGN KEY (deal_id) REFERENCES public.deals(id), + CONSTRAINT campaign_assets_uploaded_by_fkey FOREIGN KEY (uploaded_by) REFERENCES public.profiles(id) +); +CREATE TABLE public.campaign_deliverables ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + campaign_id uuid NOT NULL, + platform text, + content_type text, + quantity integer DEFAULT 1, + guidance text, + required boolean DEFAULT true, + created_at timestamp with time zone DEFAULT now(), + CONSTRAINT campaign_deliverables_pkey PRIMARY KEY (id), + CONSTRAINT campaign_deliverables_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES public.campaigns(id) +); +CREATE TABLE public.campaign_invites ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + campaign_id uuid NOT NULL, + brand_id uuid NOT NULL, + creator_id uuid NOT NULL, + message text, + proposed_amount numeric, + status USER-DEFINED DEFAULT 'pending'::invite_status, + sent_at timestamp with time zone, + responded_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now(), + CONSTRAINT campaign_invites_pkey PRIMARY KEY (id), + CONSTRAINT campaign_invites_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES public.campaigns(id), + CONSTRAINT campaign_invites_brand_id_fkey FOREIGN KEY (brand_id) REFERENCES public.brands(id), + CONSTRAINT campaign_invites_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.creators(id) +); +CREATE TABLE public.campaign_payments ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + deal_id uuid NOT NULL, + amount numeric NOT NULL, + currency text DEFAULT 'INR'::text, + method text, + status USER-DEFINED DEFAULT 'pending'::payment_status, + external_payment_ref text, + metadata jsonb DEFAULT '{}'::jsonb, + created_at timestamp with time zone DEFAULT now(), + paid_at timestamp with time zone, + CONSTRAINT campaign_payments_pkey PRIMARY KEY (id), + CONSTRAINT campaign_payments_deal_id_fkey FOREIGN KEY (deal_id) REFERENCES public.deals(id) +); +CREATE TABLE public.campaign_performance ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + campaign_id uuid NOT NULL, + deal_id uuid, + report_source text, + recorded_at timestamp with time zone NOT NULL DEFAULT now(), + impressions bigint, + clicks bigint, + views bigint, + watch_time bigint, + engagements bigint, + conversions bigint, + revenue numeric, + raw jsonb DEFAULT '{}'::jsonb, + CONSTRAINT campaign_performance_pkey PRIMARY KEY (id), + CONSTRAINT campaign_performance_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES public.campaigns(id), + CONSTRAINT campaign_performance_deal_id_fkey FOREIGN KEY (deal_id) REFERENCES public.deals(id) +); +CREATE TABLE public.campaigns ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + brand_id uuid NOT NULL, + title text NOT NULL, + slug text UNIQUE, + short_description text, + description text, + status text NOT NULL DEFAULT 'draft'::text, + platforms text[] DEFAULT ARRAY[]::text[], + deliverables jsonb DEFAULT '[]'::jsonb, + target_audience jsonb DEFAULT '{}'::jsonb, + budget_min numeric, + budget_max numeric, + preferred_creator_niches text[] DEFAULT ARRAY[]::text[], + preferred_creator_followers_range text, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now(), + published_at timestamp with time zone, + starts_at timestamp with time zone, + ends_at timestamp with time zone, + is_featured boolean DEFAULT false, + CONSTRAINT campaigns_pkey PRIMARY KEY (id), + CONSTRAINT campaigns_brand_id_fkey FOREIGN KEY (brand_id) REFERENCES public.brands(id) +); +CREATE TABLE public.creators ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + user_id uuid NOT NULL UNIQUE, + display_name text NOT NULL, + bio text, + tagline text, + profile_picture_url text, + cover_image_url text, + website_url text, + youtube_url text, + youtube_handle text, + youtube_subscribers integer, + instagram_url text, + instagram_handle text, + instagram_followers integer, + tiktok_url text, + tiktok_handle text, + tiktok_followers integer, + twitter_url text, + twitter_handle text, + twitter_followers integer, + twitch_url text, + twitch_handle text, + twitch_followers integer, + linkedin_url text, + facebook_url text, + primary_niche text NOT NULL, + secondary_niches text[] DEFAULT ARRAY[]::text[], + content_types text[] DEFAULT ARRAY[]::text[], + content_language text[] DEFAULT ARRAY[]::text[], + total_followers integer DEFAULT 0, + total_reach integer, + average_views integer, + engagement_rate double precision, + audience_age_primary text, + audience_age_secondary text[] DEFAULT ARRAY[]::text[], + audience_gender_split jsonb, + audience_locations jsonb, + audience_interests text[] DEFAULT ARRAY[]::text[], + average_engagement_per_post integer, + posting_frequency text, + best_performing_content_type text, + peak_posting_times jsonb, + years_of_experience integer, + content_creation_full_time boolean DEFAULT false, + team_size integer DEFAULT 1, + equipment_quality text, + editing_software text[] DEFAULT ARRAY[]::text[], + collaboration_types text[] DEFAULT ARRAY[]::text[], + preferred_brands_style text[] DEFAULT ARRAY[]::text[], + not_interested_in text[] DEFAULT ARRAY[]::text[], + rate_per_post numeric, + rate_per_video numeric, + rate_per_story numeric, + rate_per_reel numeric, + rate_negotiable boolean DEFAULT true, + accepts_product_only_deals boolean DEFAULT false, + minimum_deal_value numeric, + preferred_payment_terms text, + portfolio_links text[] DEFAULT ARRAY[]::text[], + past_brand_collaborations text[] DEFAULT ARRAY[]::text[], + case_study_links text[] DEFAULT ARRAY[]::text[], + media_kit_url text, + email_verified boolean DEFAULT false, + phone_verified boolean DEFAULT false, + identity_verified boolean DEFAULT false, + profile_completion_percentage integer DEFAULT 0, + is_active boolean DEFAULT true, + is_featured boolean DEFAULT false, + is_verified_creator boolean DEFAULT false, + featured_until timestamp with time zone, + ai_profile_summary text, + search_keywords text[] DEFAULT ARRAY[]::text[], + matching_score_base double precision DEFAULT 50.0, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now(), + last_active_at timestamp with time zone DEFAULT now(), + social_platforms jsonb, + CONSTRAINT creators_pkey PRIMARY KEY (id), + CONSTRAINT creators_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.profiles(id) +); +CREATE TABLE public.deals ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + campaign_id uuid, + application_id uuid, + brand_id uuid NOT NULL, + creator_id uuid NOT NULL, + agreed_amount numeric, + payment_schedule jsonb DEFAULT '[]'::jsonb, + terms jsonb DEFAULT '{}'::jsonb, + status USER-DEFINED DEFAULT 'draft'::deal_status, + starts_at timestamp with time zone, + ends_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now(), + CONSTRAINT deals_pkey PRIMARY KEY (id), + CONSTRAINT deals_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES public.campaigns(id), + CONSTRAINT deals_application_id_fkey FOREIGN KEY (application_id) REFERENCES public.campaign_applications(id), + CONSTRAINT deals_brand_id_fkey FOREIGN KEY (brand_id) REFERENCES public.brands(id), + CONSTRAINT deals_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.creators(id) +); +CREATE TABLE public.match_scores ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + creator_id uuid NOT NULL, + brand_id uuid NOT NULL, + score double precision NOT NULL, + reasons jsonb DEFAULT '[]'::jsonb, + computed_at timestamp with time zone DEFAULT now(), + CONSTRAINT match_scores_pkey PRIMARY KEY (id), + CONSTRAINT match_scores_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.creators(id), + CONSTRAINT match_scores_brand_id_fkey FOREIGN KEY (brand_id) REFERENCES public.brands(id) +); +CREATE TABLE public.profiles ( + id uuid NOT NULL, + name text NOT NULL, + role text NOT NULL CHECK (role = ANY (ARRAY['Creator'::text, 'Brand'::text])), + created_at timestamp with time zone DEFAULT timezone('utc'::text, now()), + onboarding_completed boolean DEFAULT false, + CONSTRAINT profiles_pkey PRIMARY KEY (id), + CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) +); + + + diff --git a/backend/app/api/routes/campaigns.py b/backend/app/api/routes/campaigns.py new file mode 100644 index 0000000..d2daa70 --- /dev/null +++ b/backend/app/api/routes/campaigns.py @@ -0,0 +1,379 @@ +""" +Campaign management routes for brand users. +""" +from fastapi import APIRouter, HTTPException, Depends, Query +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime, timezone +from app.core.supabase_clients import supabase_anon +from uuid import UUID + +router = APIRouter() + + +class CampaignCreate(BaseModel): + """Schema for creating a new campaign.""" + title: str = Field(..., min_length=1, max_length=255) + slug: Optional[str] = None + short_description: Optional[str] = None + description: Optional[str] = None + status: str = Field(default="draft", pattern="^(draft|active|paused|completed|archived)$") + platforms: List[str] = Field(default_factory=list) + deliverables: Optional[List[dict]] = Field(default_factory=list) + target_audience: Optional[dict] = Field(default_factory=dict) + budget_min: Optional[float] = None + budget_max: Optional[float] = None + preferred_creator_niches: List[str] = Field(default_factory=list) + preferred_creator_followers_range: Optional[str] = None + starts_at: Optional[datetime] = None + ends_at: Optional[datetime] = None + is_featured: bool = False + + +class CampaignUpdate(BaseModel): + """Schema for updating an existing campaign.""" + title: Optional[str] = None + slug: Optional[str] = None + short_description: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + platforms: Optional[List[str]] = None + deliverables: Optional[List[dict]] = None + target_audience: Optional[dict] = None + budget_min: Optional[float] = None + budget_max: Optional[float] = None + preferred_creator_niches: Optional[List[str]] = None + preferred_creator_followers_range: Optional[str] = None + starts_at: Optional[datetime] = None + ends_at: Optional[datetime] = None + is_featured: Optional[bool] = None + + +class CampaignResponse(BaseModel): + """Schema for campaign response.""" + id: str + brand_id: str + title: str + slug: Optional[str] + short_description: Optional[str] + description: Optional[str] + status: str + platforms: List[str] + deliverables: List[dict] + target_audience: dict + budget_min: Optional[float] + budget_max: Optional[float] + preferred_creator_niches: List[str] + preferred_creator_followers_range: Optional[str] + created_at: datetime + updated_at: datetime + published_at: Optional[datetime] + starts_at: Optional[datetime] + ends_at: Optional[datetime] + is_featured: bool + + +async def get_brand_id_from_user(user_id: str) -> str: + """Get brand ID from user ID.""" + supabase = supabase_anon + + try: + response = supabase.table("brands").select("id").eq("user_id", user_id).single().execute() + + if not response.data: + raise HTTPException(status_code=404, detail="Brand profile not found") + + return response.data["id"] + except HTTPException: + raise + except Exception as e: + if "PGRST116" in str(e): # No rows returned + raise HTTPException(status_code=404, detail="Brand profile not found") from e + raise HTTPException(status_code=500, detail=f"Error fetching brand profile: {str(e)}") from e + + +@router.post("/campaigns", response_model=CampaignResponse, status_code=201) +async def create_campaign(campaign: CampaignCreate, user_id: str = Query(..., description="User ID from authentication")): + """ + Create a new campaign for a brand. + + - **user_id**: The authenticated user's ID (should be passed from auth middleware) + - **campaign**: Campaign details matching the database schema + """ + supabase = supabase_anon + + # Get brand ID from user ID + brand_id = await get_brand_id_from_user(user_id) + + # Generate slug if not provided + if not campaign.slug: + import re + slug = re.sub(r'[^a-z0-9]+', '-', campaign.title.lower()).strip('-') + # Ensure uniqueness by checking existing slugs (race condition handled below) + base_slug = f"{slug}-{datetime.now(timezone.utc).strftime('%Y%m%d')}" + campaign.slug = base_slug + counter = 1 + while True: + existing = supabase.table("campaigns").select("id").eq("slug", campaign.slug).execute() + if not existing.data: + break + campaign.slug = f"{base_slug}-{counter}" + counter += 1 + + import time + max_attempts = 5 + for attempt in range(max_attempts): + try: + # Prepare campaign data + campaign_data = { + "brand_id": brand_id, + "title": campaign.title, + "slug": campaign.slug, + "short_description": campaign.short_description, + "description": campaign.description, + "status": campaign.status, + "platforms": campaign.platforms, + "deliverables": campaign.deliverables, + "target_audience": campaign.target_audience, + "budget_min": campaign.budget_min, + "budget_max": campaign.budget_max, + "preferred_creator_niches": campaign.preferred_creator_niches, + "preferred_creator_followers_range": campaign.preferred_creator_followers_range, + "starts_at": campaign.starts_at.isoformat() if campaign.starts_at else None, + "ends_at": campaign.ends_at.isoformat() if campaign.ends_at else None, + "is_featured": campaign.is_featured, + } + + # If status is active, set published_at + if campaign.status == "active": + campaign_data["published_at"] = datetime.now(timezone.utc).isoformat() + + # Insert campaign + response = supabase.table("campaigns").insert(campaign_data).execute() + + if not response.data: + raise HTTPException(status_code=500, detail="Failed to create campaign") + + return response.data[0] + + except HTTPException: + raise + except Exception as e: + # Check for unique constraint violation on slug + if "duplicate key value violates unique constraint" in str(e) and "slug" in str(e): + # Regenerate slug and retry + campaign.slug = f"{base_slug}-{int(time.time() * 1000)}" + continue + raise HTTPException(status_code=500, detail=f"Error creating campaign: {str(e)}") from e + raise HTTPException(status_code=500, detail="Could not generate a unique slug for the campaign after multiple attempts.") + + + +@router.get("/campaigns", response_model=List[CampaignResponse]) +async def get_campaigns( + user_id: str = Query(..., description="User ID from authentication"), + status: Optional[str] = Query(None, description="Filter by status"), + search: Optional[str] = Query(None, description="Search by title or description"), + platform: Optional[str] = Query(None, description="Filter by platform"), + budget_min: Optional[float] = Query(None, description="Minimum budget"), + budget_max: Optional[float] = Query(None, description="Maximum budget"), + starts_after: Optional[datetime] = Query(None, description="Campaign starts after this date"), + ends_before: Optional[datetime] = Query(None, description="Campaign ends before this date"), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0) +): + """ + Get all campaigns for a brand with optional filters. + + - **user_id**: The authenticated user's ID + - **status**: Optional filter by campaign status + - **search**: Optional search term for title or description + - **platform**: Optional filter by platform + - **budget_min**: Optional minimum budget + - **budget_max**: Optional maximum budget + - **starts_after**: Optional filter for campaigns starting after this date + - **ends_before**: Optional filter for campaigns ending before this date + - **limit**: Maximum number of results (default: 50, max: 100) + - **offset**: Number of results to skip for pagination + """ + supabase = supabase_anon + + # Get brand ID from user ID + brand_id = await get_brand_id_from_user(user_id) + + try: + # Build query + query = supabase.table("campaigns").select("*").eq("brand_id", brand_id) + + # Apply filters + if status: + query = query.eq("status", status) + + if search: + # Search in title and description + query = query.or_(f"title.ilike.%{search}%,description.ilike.%{search}%") + + if platform: + query = query.contains("platforms", [platform]) + + if budget_min is not None: + query = query.gte("budget_min", budget_min) + + if budget_max is not None: + query = query.lte("budget_max", budget_max) + + if starts_after: + query = query.gte("starts_at", starts_after.isoformat()) + + if ends_before: + query = query.lte("ends_at", ends_before.isoformat()) + + # Apply pagination and ordering + query = query.order("created_at", desc=True).range(offset, offset + limit - 1) + + response = query.execute() + + return response.data if response.data else [] + + except HTTPException: + raise + except Exception as e: + # Log the full error internally + import logging + logger = logging.getLogger(__name__) + logger.exception("Error fetching campaigns") + raise HTTPException( + status_code=500, + detail="Error fetching campaigns. Please contact support if the issue persists." + ) from e + + +@router.get("/campaigns/{campaign_id}", response_model=CampaignResponse) +async def get_campaign( + campaign_id: str, + user_id: str = Query(..., description="User ID from authentication") +): + """ + Get a single campaign by ID. + + - **campaign_id**: The campaign ID + - **user_id**: The authenticated user's ID + """ + supabase = supabase_anon + + # Get brand ID from user ID + brand_id = await get_brand_id_from_user(user_id) + + try: + # Fetch campaign and verify ownership + response = supabase.table("campaigns").select("*").eq("id", campaign_id).eq("brand_id", brand_id).single().execute() + + if not response.data: + raise HTTPException(status_code=404, detail="Campaign not found") + + return response.data + + except HTTPException: + raise + except Exception as e: + if "PGRST116" in str(e): # No rows returned + raise HTTPException(status_code=404, detail="Campaign not found") from e + raise HTTPException(status_code=500, detail=f"Error fetching campaign: {str(e)}") from e + + +@router.put("/campaigns/{campaign_id}", response_model=CampaignResponse) +async def update_campaign( + campaign_id: str, + campaign: CampaignUpdate, + user_id: str = Query(..., description="User ID from authentication") +): + """ + Update an existing campaign. + + - **campaign_id**: The campaign ID + - **campaign**: Updated campaign details + - **user_id**: The authenticated user's ID + """ + supabase = supabase_anon + + # Get brand ID from user ID + brand_id = await get_brand_id_from_user(user_id) + + try: + # Verify campaign exists and belongs to this brand + existing = supabase.table("campaigns").select("id, published_at").eq("id", campaign_id).eq("brand_id", brand_id).single().execute() + + if not existing.data: + raise HTTPException(status_code=404, detail="Campaign not found") + + # Prepare update data (only include non-None fields) using Pydantic v2 API + update_data = campaign.model_dump(exclude_none=True) + + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + # Update timestamp + update_data["updated_at"] = datetime.now(timezone.utc).isoformat() + + # Serialize datetime fields to ISO format + if "starts_at" in update_data and update_data["starts_at"] is not None: + if isinstance(update_data["starts_at"], datetime): + update_data["starts_at"] = update_data["starts_at"].isoformat() + if "ends_at" in update_data and update_data["ends_at"] is not None: + if isinstance(update_data["ends_at"], datetime): + update_data["ends_at"] = update_data["ends_at"].isoformat() + + # If status changes to active and published_at is not set, set it + if update_data.get("status") == "active" and not existing.data.get("published_at"): + update_data["published_at"] = datetime.now(timezone.utc).isoformat() + + # Update campaign + response = supabase.table("campaigns").update(update_data).eq("id", campaign_id).execute() + + if not response.data: + raise HTTPException(status_code=500, detail="Failed to update campaign") + + return response.data[0] + + except HTTPException: + raise + except Exception as e: + if "PGRST116" in str(e): + raise HTTPException(status_code=404, detail="Campaign not found") from e + raise HTTPException(status_code=500, detail=f"Error updating campaign: {str(e)}") from e + + +@router.delete("/campaigns/{campaign_id}", status_code=204) +async def delete_campaign( + campaign_id: str, + user_id: str = Query(..., description="User ID from authentication") +): + """ + Delete a campaign. + + - **campaign_id**: The campaign ID + - **user_id**: The authenticated user's ID + """ + supabase = supabase_anon + + # Get brand ID from user ID + brand_id = await get_brand_id_from_user(user_id) + + try: + # Verify campaign exists and belongs to this brand + existing = supabase.table("campaigns").select("id").eq("id", campaign_id).eq("brand_id", brand_id).single().execute() + + if not existing.data: + raise HTTPException(status_code=404, detail="Campaign not found") + + # Delete campaign + supabase.table("campaigns").delete().eq("id", campaign_id).execute() + + return None + + except HTTPException: + raise + except Exception as e: + if "PGRST116" in str(e): + raise HTTPException(status_code=404, detail="Campaign not found") from e + raise HTTPException(status_code=500, detail=f"Error deleting campaign: {str(e)}") from e diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py index 6f42dbf..560eb49 100644 --- a/backend/app/api/routes/health.py +++ b/backend/app/api/routes/health.py @@ -17,7 +17,7 @@ def check_supabase(): This endpoint attempts to query Supabase to verify the connection. """ try: - from app.services.supabase_client import supabase + from app.core.supabase_clients import supabase_anon as supabase # Attempt a simple query to verify connection response = supabase.table("_supabase_test").select("*").limit(1).execute() diff --git a/backend/app/main.py b/backend/app/main.py index 47a14f2..c79345a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,9 +2,10 @@ from fastapi.middleware.cors import CORSMiddleware import os from app.api.routes import health -from app.services.supabase_client import supabase +from app.core.supabase_clients import supabase_anon as supabase from app.api.routes import auth from app.api.routes import gemini_generate +from app.api.routes import campaigns app = FastAPI(title="Inpact Backend", version="0.1.0") # Verify Supabase client initialization on startup @@ -30,6 +31,7 @@ app.include_router(gemini_generate.router) app.include_router(health.router) app.include_router(auth.router) +app.include_router(campaigns.router) @app.get("/") def root(): diff --git a/docs/database/schema-reference.md b/docs/database/schema-reference.md new file mode 100644 index 0000000..237c4ea --- /dev/null +++ b/docs/database/schema-reference.md @@ -0,0 +1,126 @@ +# Supabase Database Schema Reference + +> **Note:** This file is for reference/documentation only. It is not intended to be run as a migration or executed directly. The SQL below is a snapshot of the schema as exported from Supabase for context and developer understanding. + +--- + +## About + +This file contains the exported DDL (Data Definition Language) statements for the database schema used in this project. It is provided for documentation and onboarding purposes. For actual migrations and schema changes, use the project's migration tool and scripts. + +--- + +## Schema + +```sql +-- Table for user profiles +create table if not exists profiles ( + id uuid references auth.users(id) on delete cascade, + name text not null, + role text check (role in ('Creator', 'Brand')) not null, + created_at timestamp with time zone default timezone('utc', now()), + primary key (id) +); + +-- Table: brands +CREATE TABLE public.brands ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + user_id uuid NOT NULL UNIQUE, + company_name text NOT NULL, + company_tagline text, + company_description text, + company_logo_url text, + company_cover_image_url text, + industry text NOT NULL, + sub_industry text[] DEFAULT ARRAY[]::text[], + company_size text, + founded_year integer, + headquarters_location text, + company_type text, + website_url text NOT NULL, + contact_email text, + contact_phone text, + social_media_links jsonb, + target_audience_age_groups text[] DEFAULT ARRAY[]::text[], + target_audience_gender text[] DEFAULT ARRAY[]::text[], + target_audience_locations text[] DEFAULT ARRAY[]::text[], + target_audience_interests text[] DEFAULT ARRAY[]::text[], + target_audience_income_level text[] DEFAULT ARRAY[]::text[], + target_audience_description text, + brand_values text[] DEFAULT ARRAY[]::text[], + brand_personality text[] DEFAULT ARRAY[]::text[], + brand_voice text, + brand_colors jsonb, + marketing_goals text[] DEFAULT ARRAY[]::text[], + campaign_types_interested text[] DEFAULT ARRAY[]::text[], + preferred_content_types text[] DEFAULT ARRAY[]::text[], + preferred_platforms text[] DEFAULT ARRAY[]::text[], + campaign_frequency text, + monthly_marketing_budget numeric, + influencer_budget_percentage double precision, + budget_per_campaign_min numeric, + budget_per_campaign_max numeric, + typical_deal_size numeric, + payment_terms text, + offers_product_only_deals boolean DEFAULT false, + offers_affiliate_programs boolean DEFAULT false, + affiliate_commission_rate double precision, + preferred_creator_niches text[] DEFAULT ARRAY[]::text[], + preferred_creator_size text[] DEFAULT ARRAY[]::text[], + preferred_creator_locations text[] DEFAULT ARRAY[]::text[], + minimum_followers_required integer, + minimum_engagement_rate double precision, + content_dos text[] DEFAULT ARRAY[]::text[], + content_donts text[] DEFAULT ARRAY[]::text[], + brand_safety_requirements text[] DEFAULT ARRAY[]::text[], + competitor_brands text[] DEFAULT ARRAY[]::text[], + exclusivity_required boolean DEFAULT false, + exclusivity_duration_months integer, + past_campaigns_count integer DEFAULT 0, + successful_partnerships text[] DEFAULT ARRAY[]::text[], + case_studies text[] DEFAULT ARRAY[]::text[], + average_campaign_roi double precision, + products_services text[] DEFAULT ARRAY[]::text[], + product_price_range text, + product_categories text[] DEFAULT ARRAY[]::text[], + seasonal_products boolean DEFAULT false, + product_catalog_url text, + business_verified boolean DEFAULT false, + payment_verified boolean DEFAULT false, + tax_id_verified boolean DEFAULT false, + profile_completion_percentage integer DEFAULT 0, + is_active boolean DEFAULT true, + is_featured boolean DEFAULT false, + is_verified_brand boolean DEFAULT false, + subscription_tier text DEFAULT 'free'::text, + featured_until timestamp with time zone, + ai_profile_summary text, + search_keywords text[] DEFAULT ARRAY[]::text[], + matching_score_base double precision DEFAULT 50.0, + total_deals_posted integer DEFAULT 0, + total_deals_completed integer DEFAULT 0, + total_spent numeric DEFAULT 0, + average_deal_rating double precision, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now(), + last_active_at timestamp with time zone DEFAULT now(), + CONSTRAINT brands_pkey PRIMARY KEY (id), + CONSTRAINT brands_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.profiles(id) +); + +-- ...existing code for other tables... +``` + +--- + +## How to Use + +- Use this file for reference only. Do not run directly against your database. +- For schema changes, use the migration scripts and tools defined in this project. +- If you need to restore or migrate, use the official migration pipeline or tools. + +--- + +## Source + +This schema was exported from Supabase using the "Save as SQL" feature for developer context. diff --git a/frontend/app/brand/campaigns/create/page.tsx b/frontend/app/brand/campaigns/create/page.tsx new file mode 100644 index 0000000..b3ec9c5 --- /dev/null +++ b/frontend/app/brand/campaigns/create/page.tsx @@ -0,0 +1,681 @@ +"use client"; + +import AuthGuard from "@/components/auth/AuthGuard"; +import SlidingMenu from "@/components/SlidingMenu"; +import { createCampaign } from "@/lib/campaignApi"; +import { + AGE_GROUP_OPTIONS, + CampaignDeliverable, + CampaignFormData, + CONTENT_TYPE_OPTIONS, + FOLLOWER_RANGE_OPTIONS, + GENDER_OPTIONS, + INCOME_LEVEL_OPTIONS, + NICHE_OPTIONS, + PLATFORM_OPTIONS, +} from "@/types/campaign"; +import { ArrowLeft, Eye, Plus, Save, Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export default function CreateCampaignPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [formData, setFormData] = useState({ + title: "", + short_description: "", + description: "", + status: "draft", + platforms: [], + deliverables: [], + target_audience: {}, + budget_min: "", + budget_max: "", + preferred_creator_niches: [], + preferred_creator_followers_range: "", + starts_at: "", + ends_at: "", + }); + + const [newDeliverable, setNewDeliverable] = useState({ + platform: "", + content_type: "", + quantity: 1, + guidance: "", + required: true, + }); + + const updateField = (field: keyof CampaignFormData, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const toggleArrayField = ( + field: "platforms" | "preferred_creator_niches", + value: string + ) => { + setFormData((prev) => ({ + ...prev, + [field]: prev[field].includes(value) + ? prev[field].filter((item) => item !== value) + : [...prev[field], value], + })); + }; + + const updateTargetAudience = (field: string, value: any) => { + setFormData((prev) => ({ + ...prev, + target_audience: { + ...prev.target_audience, + [field]: value, + }, + })); + }; + + const toggleTargetAudienceArray = (field: string, value: string) => { + const current = + (formData.target_audience[ + field as keyof typeof formData.target_audience + ] as string[]) || []; + updateTargetAudience( + field, + current.includes(value) + ? current.filter((item) => item !== value) + : [...current, value] + ); + }; + + const addDeliverable = () => { + if (!newDeliverable.platform || !newDeliverable.content_type) { + setError( + "Please select both platform and content type for the deliverable" + ); + return; + } + setError(null); // Clear any previous errors + setFormData((prev) => ({ + ...prev, + deliverables: [...prev.deliverables, { ...newDeliverable }], + })); + setNewDeliverable({ + platform: "", + content_type: "", + quantity: 1, + guidance: "", + required: true, + }); + }; + + const removeDeliverable = (index: number) => { + setFormData((prev) => ({ + ...prev, + deliverables: prev.deliverables.filter((_, i) => i !== index), + })); + }; + + const validateForm = (): boolean => { + if (!formData.title.trim()) { + setError("Campaign title is required"); + return false; + } + if (formData.budget_min && formData.budget_max) { + if (parseFloat(formData.budget_min) > parseFloat(formData.budget_max)) { + setError("Minimum budget cannot be greater than maximum budget"); + return false; + } + } + if (formData.starts_at && formData.ends_at) { + if (new Date(formData.starts_at) > new Date(formData.ends_at)) { + setError("Start date cannot be after end date"); + return false; + } + } + return true; + }; + + const handleSubmit = async (status: "draft" | "active") => { + setError(null); + + if (!validateForm()) { + return; + } + + try { + setLoading(true); + const submitData = { + ...formData, + status, + budget_min: formData.budget_min + ? parseFloat(formData.budget_min) + : undefined, + budget_max: formData.budget_max + ? parseFloat(formData.budget_max) + : undefined, + starts_at: formData.starts_at || undefined, + ends_at: formData.ends_at || undefined, + }; + await createCampaign(submitData); + router.push("/brand/campaigns"); + } catch (err: any) { + setError(err.message || "Failed to create campaign"); + } finally { + setLoading(false); + } + }; + + return ( + +
+ + +
+ {/* Header */} +
+ +

+ Create New Campaign +

+

+ Fill out the details below to launch your influencer marketing + campaign +

+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + +
+ {/* Basic Information */} +
+

+ Basic Information +

+
+
+ + updateField("title", e.target.value)} + placeholder="e.g., Summer Product Launch 2024" + className="w-full rounded-lg border border-gray-300 px-4 py-3 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none" + required + /> +
+ +
+ + + updateField("short_description", e.target.value) + } + placeholder="Brief one-liner about your campaign" + className="w-full rounded-lg border border-gray-300 px-4 py-3 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none" + /> +
+ +
+ +