Add UGC Content Creator microservice (10th professional role)

- New service at apps/ugc_content_creators (port 8095)
- DB model + repository in crates/db/src/models/ugc_content_creator.rs
- Migration: ugc_content_creator_profiles table with platforms, content_niches,
  content_formats, follower_count, handles, and standard status/timestamps
- Contracts: is_professional_profile_approved() handles UGC_CONTENT_CREATOR case
- Gateway: routes /api/ugc-content-creators to new service
- Workspace Cargo.toml updated with new member

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar 2026-04-05 21:14:02 +02:00
parent f9995586e2
commit 89b055b329
10 changed files with 197 additions and 0 deletions

View file

@ -15,6 +15,7 @@ members = [
"apps/social_media_managers", "apps/social_media_managers",
"apps/fitness_trainers", "apps/fitness_trainers",
"apps/catering_services", "apps/catering_services",
"apps/ugc_content_creators",
"crates/contracts", "crates/contracts",
"crates/db", "crates/db",
"crates/auth", "crates/auth",

View file

@ -26,6 +26,7 @@ struct Services {
social_media_managers_url: String, social_media_managers_url: String,
fitness_trainers_url: String, fitness_trainers_url: String,
catering_services_url: String, catering_services_url: String,
ugc_content_creators_url: String,
// ── Payments ───────────────────────────────────────────────────────── // ── Payments ─────────────────────────────────────────────────────────
payments_url: String, payments_url: String,
// ── Employees (Internal) ───────────────────────────────────────────── // ── Employees (Internal) ─────────────────────────────────────────────
@ -62,6 +63,8 @@ impl Services {
.unwrap_or_else(|_| "http://localhost:8092".to_string()), .unwrap_or_else(|_| "http://localhost:8092".to_string()),
catering_services_url: std::env::var("CATERING_SERVICES_SERVICE_URL") catering_services_url: std::env::var("CATERING_SERVICES_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8093".to_string()), .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") payments_url: std::env::var("PAYMENTS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8094".to_string()), .unwrap_or_else(|_| "http://localhost:8094".to_string()),
employees_url: std::env::var("EMPLOYEES_SERVICE_URL") employees_url: std::env::var("EMPLOYEES_SERVICE_URL")
@ -153,6 +156,9 @@ impl Services {
else if path.starts_with("/api/catering-services") { else if path.starts_with("/api/catering-services") {
Some(self.catering_services_url.clone()) Some(self.catering_services_url.clone())
} }
else if path.starts_with("/api/ugc-content-creators") {
Some(self.ugc_content_creators_url.clone())
}
// ── Payments + Invoices ─────────────────────────────────────────── // ── Payments + Invoices ───────────────────────────────────────────
else if path.starts_with("/api/payments") else if path.starts_with("/api/payments")
|| path.starts_with("/api/admin/invoices") || path.starts_with("/api/admin/invoices")

View file

@ -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" }

View file

@ -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<ProfessionState> {
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<ProfessionState>, 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<ProfessionState>,
auth: AuthUser,
Json(payload): Json<UpsertUgcContentCreatorProfilePayload>,
) -> 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(),
}
}

View file

@ -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();
}

View file

@ -288,6 +288,12 @@ async fn is_professional_profile_approved(
.fetch_optional(pool) .fetch_optional(pool)
.await? .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, _ => None,
}; };

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS ugc_content_creator_profiles;

View file

@ -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);

View file

@ -16,6 +16,7 @@ pub mod graphic_designer;
pub mod social_media_manager; pub mod social_media_manager;
pub mod fitness_trainer; pub mod fitness_trainer;
pub mod catering_service; pub mod catering_service;
pub mod ugc_content_creator;
pub mod requirement; pub mod requirement;
pub mod lead_request; pub mod lead_request;
pub mod application; pub mod application;

View file

@ -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<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertUgcContentCreatorProfilePayload {
pub display_name: Option<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub custom_data: Option<serde_json::Value>,
}
pub struct UgcContentCreatorRepository;
impl UgcContentCreatorRepository {
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Option<UgcContentCreatorProfile>, 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<UgcContentCreatorProfile, sqlx::Error> {
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
}
}