diff --git a/Cargo.toml b/Cargo.toml index 7df9f4f..21ed9c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "apps/social_media_managers", "apps/fitness_trainers", "apps/catering_services", + "apps/ugc_content_creators", "crates/contracts", "crates/db", "crates/auth", diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 307f3e7..2977d2a 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -26,6 +26,7 @@ struct Services { social_media_managers_url: String, fitness_trainers_url: String, catering_services_url: String, + ugc_content_creators_url: String, // ── Payments ───────────────────────────────────────────────────────── payments_url: String, // ── Employees (Internal) ───────────────────────────────────────────── @@ -62,6 +63,8 @@ impl Services { .unwrap_or_else(|_| "http://localhost:8092".to_string()), catering_services_url: std::env::var("CATERING_SERVICES_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:8093".to_string()), + ugc_content_creators_url: std::env::var("UGC_CONTENT_CREATORS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:8095".to_string()), payments_url: std::env::var("PAYMENTS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:8094".to_string()), employees_url: std::env::var("EMPLOYEES_SERVICE_URL") @@ -153,6 +156,9 @@ impl Services { else if path.starts_with("/api/catering-services") { Some(self.catering_services_url.clone()) } + else if path.starts_with("/api/ugc-content-creators") { + Some(self.ugc_content_creators_url.clone()) + } // ── Payments + Invoices ─────────────────────────────────────────── else if path.starts_with("/api/payments") || path.starts_with("/api/admin/invoices") diff --git a/apps/ugc_content_creators/Cargo.toml b/apps/ugc_content_creators/Cargo.toml new file mode 100644 index 0000000..8978704 --- /dev/null +++ b/apps/ugc_content_creators/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ugc_content_creators" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +db = { path = "../../crates/db" } +auth = { path = "../../crates/auth" } +contracts = { path = "../../crates/contracts" } +cache = { path = "../../crates/cache" } diff --git a/apps/ugc_content_creators/src/handlers.rs b/apps/ugc_content_creators/src/handlers.rs new file mode 100644 index 0000000..d77ef6e --- /dev/null +++ b/apps/ugc_content_creators/src/handlers.rs @@ -0,0 +1,28 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; +use db::models::ugc_content_creator::{UgcContentCreatorRepository, UpsertUgcContentCreatorProfilePayload}; +use contracts::{auth_middleware::AuthUser, ProfessionState}; + +pub fn router() -> Router { + Router::new() + .route("/profile/me", get(get_profile).patch(update_profile)) + .merge(contracts::profession_shared::shared_routes("UGC_CONTENT_CREATOR")) +} + +async fn get_profile(State(state): State, auth: AuthUser) -> impl IntoResponse { + match UgcContentCreatorRepository::get_by_user_id(&state.pool, auth.user_id).await { + Ok(Some(p)) => (StatusCode::OK, Json(p)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Profile not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_profile( + State(state): State, + auth: AuthUser, + Json(payload): Json, +) -> impl IntoResponse { + match UgcContentCreatorRepository::upsert(&state.pool, auth.user_id, payload).await { + Ok(p) => (StatusCode::OK, Json(p)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/apps/ugc_content_creators/src/main.rs b/apps/ugc_content_creators/src/main.rs new file mode 100644 index 0000000..6ea2f55 --- /dev/null +++ b/apps/ugc_content_creators/src/main.rs @@ -0,0 +1,49 @@ +mod handlers; + +use axum::{routing::get, Router}; +use std::net::SocketAddr; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use contracts::ProfessionState; + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to postgres"); + + let redis_url = std::env::var("REDIS_URL") + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let redis = cache::connect(&redis_url) + .await + .expect("Failed to connect to Redis"); + + tracing::info!("UGC Content Creators service — connected to DB and Redis"); + + let state = ProfessionState { pool, redis }; + + let app = Router::new() + .nest("/api/ugc-content-creators", handlers::router()) + .route("/health", get(|| async { "UGC Content Creators OK" })) + .with_state(state); + + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8095".to_string()) + .parse() + .expect("PORT must be a number"); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + tracing::info!("UGC Content Creators service listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 4889f38..7423921 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -288,6 +288,12 @@ async fn is_professional_profile_approved( .fetch_optional(pool) .await? } + "UGC_CONTENT_CREATOR" => { + sqlx::query_scalar::<_, String>("SELECT status FROM ugc_content_creator_profiles WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await? + } _ => None, }; diff --git a/crates/db/migrations/20260405100000_init_ugc_content_creator_schema.down.sql b/crates/db/migrations/20260405100000_init_ugc_content_creator_schema.down.sql new file mode 100644 index 0000000..401793a --- /dev/null +++ b/crates/db/migrations/20260405100000_init_ugc_content_creator_schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ugc_content_creator_profiles; diff --git a/crates/db/migrations/20260405100000_init_ugc_content_creator_schema.up.sql b/crates/db/migrations/20260405100000_init_ugc_content_creator_schema.up.sql new file mode 100644 index 0000000..9304b8f --- /dev/null +++ b/crates/db/migrations/20260405100000_init_ugc_content_creator_schema.up.sql @@ -0,0 +1,29 @@ +-- 10. UGC CONTENT CREATOR PROFILES +CREATE TABLE IF NOT EXISTS ugc_content_creator_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + display_name VARCHAR(255) NOT NULL DEFAULT '', + bio TEXT, + location VARCHAR(255), + -- Profession-specific + platforms TEXT[] DEFAULT '{}', -- e.g. ['Instagram', 'YouTube', 'TikTok'] + content_niches TEXT[] DEFAULT '{}', -- e.g. ['Beauty', 'Tech', 'Food', 'Lifestyle'] + content_formats TEXT[] DEFAULT '{}', -- e.g. ['Reels', 'Unboxing', 'Reviews', 'GRWM'] + follower_count INTEGER DEFAULT 0, + avg_views_per_post INTEGER DEFAULT 0, + has_media_kit BOOLEAN NOT NULL DEFAULT false, + instagram_handle VARCHAR(100), + youtube_channel_url VARCHAR(500), + portfolio_url VARCHAR(500), + starting_price_inr INTEGER DEFAULT 0, -- in paise + custom_data JSONB, + -- Verification & status + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ugc_content_creator_profiles_status ON ugc_content_creator_profiles(status); +CREATE INDEX IF NOT EXISTS idx_ugc_content_creator_profiles_user_id ON ugc_content_creator_profiles(user_id); diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 73a7fdf..a762400 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -16,6 +16,7 @@ pub mod graphic_designer; pub mod social_media_manager; pub mod fitness_trainer; pub mod catering_service; +pub mod ugc_content_creator; pub mod requirement; pub mod lead_request; pub mod application; diff --git a/crates/db/src/models/ugc_content_creator.rs b/crates/db/src/models/ugc_content_creator.rs new file mode 100644 index 0000000..4e12a5e --- /dev/null +++ b/crates/db/src/models/ugc_content_creator.rs @@ -0,0 +1,58 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct UgcContentCreatorProfile { + pub id: Uuid, + pub user_id: Uuid, + pub display_name: Option, + pub bio: Option, + pub location: Option, + pub custom_data: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpsertUgcContentCreatorProfilePayload { + pub display_name: Option, + pub bio: Option, + pub location: Option, + pub custom_data: Option, +} + +pub struct UgcContentCreatorRepository; + +impl UgcContentCreatorRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + UgcContentCreatorProfile, + r#"SELECT id, user_id, display_name, bio, location, + custom_data, + status, created_at, updated_at + FROM ugc_content_creator_profiles WHERE user_id = $1"#, + user_id + ).fetch_optional(pool).await + } + + pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertUgcContentCreatorProfilePayload) -> Result { + sqlx::query_as!( + UgcContentCreatorProfile, + r#"INSERT INTO ugc_content_creator_profiles (user_id, display_name, bio, location, custom_data) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id) DO UPDATE SET + display_name = COALESCE(EXCLUDED.display_name, ugc_content_creator_profiles.display_name), + bio = EXCLUDED.bio, + location = EXCLUDED.location, + custom_data = EXCLUDED.custom_data, + updated_at = NOW() + RETURNING id, user_id, display_name, bio, location, + custom_data, + status, created_at, updated_at"#, + user_id, p.display_name, p.bio, p.location, p.custom_data + ).fetch_one(pool).await + } +}