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:
parent
f9995586e2
commit
89b055b329
10 changed files with 197 additions and 0 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
18
apps/ugc_content_creators/Cargo.toml
Normal file
18
apps/ugc_content_creators/Cargo.toml
Normal 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" }
|
||||||
28
apps/ugc_content_creators/src/handlers.rs
Normal file
28
apps/ugc_content_creators/src/handlers.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/ugc_content_creators/src/main.rs
Normal file
49
apps/ugc_content_creators/src/main.rs
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS ugc_content_creator_profiles;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
58
crates/db/src/models/ugc_content_creator.rs
Normal file
58
crates/db/src/models/ugc_content_creator.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue