diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 6a0df3c..75bbd36 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -405,7 +405,38 @@ async fn view_contact( return (StatusCode::FORBIDDEN, "Access denied").into_response(); } - // Fetch applicant contact info via applicant_user_id → users + let free_views = company.free_contact_views; + let purchased_views = company.purchased_contact_views; + + if free_views <= 0 && purchased_views <= 0 { + return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({ + "error": "Contact view quota exhausted", + "code": "QUOTA_EXHAUSTED", + "requires_purchase": true, + "message": "You have used all your free contact views. Please purchase a contact view package to continue." + }))).into_response(); + } + + let used_free = free_views > 0; + + if used_free { + sqlx::query( + "UPDATE company_profiles SET free_contact_views = free_contact_views - 1, updated_at = NOW() WHERE id = $1" + ) + .bind(company.id) + .execute(&state.pool) + .await + .ok(); + } else { + sqlx::query( + "UPDATE company_profiles SET purchased_contact_views = purchased_contact_views - 1, updated_at = NOW() WHERE id = $1" + ) + .bind(company.id) + .execute(&state.pool) + .await + .ok(); + } + let contact = sqlx::query_as::<_, (Option, String, Option)>( r#" SELECT u.full_name, u.email, u.phone @@ -419,14 +450,23 @@ async fn view_contact( match contact { Ok(Some((full_name, email, phone))) => { - // Fetch updated quota to return to client - let updated_company = CompanyRepository::get_by_user_id(&state.pool, auth.user_id) - .await - .ok() - .flatten(); - let (free_remaining, purchased_remaining) = updated_company - .map(|c| (c.free_contact_views, c.purchased_contact_views)) - .unwrap_or((0, 0)); + let new_free = if used_free { free_views - 1 } else { free_views }; + let new_purchased = if used_free { purchased_views } else { purchased_views - 1 }; + + let _ = sqlx::query( + r#" + INSERT INTO notifications (user_id, title, body, notification_type, reference_id) + VALUES ($1, $2, $3, $4, $5) + "# + ) + .bind(app.applicant_user_id) + .bind("Your contact was viewed") + .bind(format!("{} viewed your application for {}", company.company_name, job.title)) + .bind("APPLICATION") + .bind(id) + .execute(&state.pool) + .await + .ok(); (StatusCode::OK, Json(serde_json::json!({ "application_id": id, @@ -434,12 +474,12 @@ async fn view_contact( "email": email, "phone": phone, "quota": { - "free_remaining": free_remaining, - "purchased_remaining": purchased_remaining, - "total_remaining": free_remaining + purchased_remaining + "used_free_view": used_free, + "free_remaining": new_free, + "purchased_remaining": new_purchased, + "total_remaining": new_free + new_purchased } - }))) - .into_response() + }))).into_response() } Ok(None) => (StatusCode::NOT_FOUND, "Applicant not found").into_response(), Err(e) => { diff --git a/apps/leads/src/lead_requests.rs b/apps/leads/src/lead_requests.rs new file mode 100644 index 0000000..0e2efa1 --- /dev/null +++ b/apps/leads/src/lead_requests.rs @@ -0,0 +1,540 @@ +use crate::AppState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct PaginationQuery { + pub page: Option, + pub limit: Option, + pub status: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SendLeadRequestPayload { + pub lead_id: Uuid, + pub message: Option, +} + +#[derive(Debug, FromRow)] +pub struct LeadRequestRow { + pub id: Uuid, + pub lead_id: Uuid, + pub user_role_profile_id: Uuid, + pub customer_user_id: Uuid, + pub status: String, + pub tracecoins_reserved: i32, + pub message: Option, + pub expires_at: chrono::DateTime, + pub accepted_at: Option>, + pub rejected_at: Option>, + pub rejected_reason: Option, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct LeadRequestResponse { + pub id: Uuid, + pub lead_id: Uuid, + pub user_role_profile_id: Uuid, + pub customer_user_id: Uuid, + pub professional_name: Option, + pub professional_role: Option, + pub customer_name: Option, + pub lead_title: Option, + pub status: String, + pub tracecoins_reserved: i32, + pub message: Option, + pub expires_at: chrono::DateTime, + pub accepted_at: Option>, + pub rejected_at: Option>, + pub rejected_reason: Option, + pub created_at: chrono::DateTime, +} + +pub fn router() -> Router> { + Router::new() + .route("/", get(list_lead_requests)) + .route("/send", post(send_lead_request)) + .route("/{id}/accept", post(accept_lead_request)) + .route("/{id}/reject", post(reject_lead_request)) + .route("/my-requests", get(my_requests)) + .route("/my-pending", get(my_pending_requests)) + .route("/customer/{lead_id}", get(get_customer_lead_requests)) +} + +fn lead_request_to_response(row: LeadRequestRow) -> LeadRequestResponse { + LeadRequestResponse { + id: row.id, + lead_id: row.lead_id, + user_role_profile_id: row.user_role_profile_id, + customer_user_id: row.customer_user_id, + professional_name: None, + professional_role: None, + customer_name: None, + lead_title: None, + status: row.status, + tracecoins_reserved: row.tracecoins_reserved, + message: row.message, + expires_at: row.expires_at, + accepted_at: row.accepted_at, + rejected_at: row.rejected_at, + rejected_reason: row.rejected_reason, + created_at: row.created_at, + } +} + +async fn list_lead_requests( + State(state): State>, + Query(q): Query, +) -> impl IntoResponse { + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + let offset = (page - 1) * limit; + + let status_filter = q.status + .as_ref() + .map(|s| format!("AND lr.status = '{}'", s)) + .unwrap_or_default(); + + let requests = match sqlx::query_as::<_, LeadRequestRow>(&format!( + r#" + SELECT lr.* FROM lead_requests lr + WHERE 1=1 {} + ORDER BY lr.created_at DESC + LIMIT {} OFFSET {} + "#, + status_filter, limit, offset + )) + .fetch_all(&state.pool) + .await + { + Ok(r) => r, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let requests: Vec = requests.into_iter().map(lead_request_to_response).collect(); + + (StatusCode::OK, Json(serde_json::json!({ + "data": requests, + "pagination": { "page": page, "limit": limit } + }))).into_response() +} + +async fn send_lead_request( + State(state): State>, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, + Json(payload): Json, +) -> impl IntoResponse { + let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + + let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM user_role_profiles WHERE user_id = $1 LIMIT 1" + ) + .bind(user_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(id)) => id, + Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found. Please complete your profile first.").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let lead = match sqlx::query_as::<_, (Uuid, String, Uuid, String, i32)>( + "SELECT id, title, customer_user_id, status, COALESCE(current_acceptances, 0) FROM leads WHERE id = $1" + ) + .bind(payload.lead_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(l)) => l, + Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if lead.3 != "OPEN" { + return (StatusCode::BAD_REQUEST, "Lead is not open for requests").into_response(); + } + + if lead.4 >= 10 { + return (StatusCode::BAD_REQUEST, "Lead has reached maximum acceptances").into_response(); + } + + let duplicate = match sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM lead_requests WHERE lead_id = $1 AND user_role_profile_id = $2 AND status IN ('PENDING', 'ACCEPTED')" + ) + .bind(payload.lead_id) + .bind(user_role_profile_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(_)) => true, + Ok(None) => false, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if duplicate { + return (StatusCode::CONFLICT, "You have already sent a request for this lead").into_response(); + } + + let request_count: (i64,) = match sqlx::query_as( + "SELECT COUNT(*) FROM lead_requests WHERE lead_id = $1 AND status IN ('PENDING', 'ACCEPTED')" + ) + .bind(payload.lead_id) + .fetch_one(&state.pool) + .await + { + Ok(c) => c, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if request_count.0 >= 20 { + return (StatusCode::CONFLICT, "Lead has reached maximum requests").into_response(); + } + + let wallet = match sqlx::query_as::<_, (Uuid, i64)>( + "SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1" + ) + .bind(user_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(w)) => w, + Ok(None) => return (StatusCode::BAD_REQUEST, "Wallet not found. Please contact support.").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let tracecoins_cost = 25; + if wallet.1 < tracecoins_cost as i64 { + return (StatusCode::PAYMENT_REQUIRED, format!("Insufficient balance. You need at least {} Tracecoins.", tracecoins_cost)).into_response(); + } + + let expires_at = chrono::Utc::now() + chrono::Duration::hours(24); + + let result = sqlx::query_as::<_, LeadRequestRow>( + r#" + INSERT INTO lead_requests (lead_id, user_role_profile_id, customer_user_id, status, tracecoins_reserved, message, expires_at) + VALUES ($1, $2, $3, 'PENDING', $4, $5, $6) + RETURNING * + "# + ) + .bind(payload.lead_id) + .bind(user_role_profile_id) + .bind(lead.2) + .bind(tracecoins_cost) + .bind(&payload.message) + .bind(expires_at) + .fetch_one(&state.pool) + .await; + + match result { + Ok(req) => { + let _ = sqlx::query( + r#" + UPDATE tracecoin_wallets SET + balance = balance - $1, + reserved = COALESCE(reserved, 0) + $1, + updated_at = NOW() + WHERE user_id = $2 + "# + ) + .bind(tracecoins_cost as i64) + .bind(user_id) + .execute(&state.pool) + .await; + + let _ = sqlx::query( + r#" + INSERT INTO notifications (user_id, title, body, notification_type, reference_id) + VALUES ($1, $2, $3, $4, $5) + "# + ) + .bind(lead.2) + .bind("New Lead Request") + .bind("You have a new lead request. Please review and respond within 24 hours.") + .bind("LEAD_REQUEST") + .bind(req.id) + .execute(&state.pool) + .await; + + let response = lead_request_to_response(req); + (StatusCode::CREATED, Json(response)).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn accept_lead_request( + State(state): State>, + Path(id): Path, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> impl IntoResponse { + let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + + let request = match sqlx::query_as::<_, LeadRequestRow>( + "SELECT * FROM lead_requests WHERE id = $1 AND status = 'PENDING'" + ) + .bind(id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(r)) => r, + Ok(None) => return (StatusCode::NOT_FOUND, "Lead request not found or already processed").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if request.customer_user_id != user_id { + return (StatusCode::FORBIDDEN, "You are not authorized to accept this request").into_response(); + } + + if request.expires_at < chrono::Utc::now() { + return (StatusCode::BAD_REQUEST, "This request has expired").into_response(); + } + + let lead_acceptances: (i32,) = match sqlx::query_as( + "SELECT COALESCE(current_acceptances, 0) FROM leads WHERE id = $1" + ) + .bind(request.lead_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(l)) => l, + Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if lead_acceptances.0 >= 10 { + return (StatusCode::BAD_REQUEST, "Lead has reached maximum acceptances").into_response(); + } + + let _ = sqlx::query( + "UPDATE lead_requests SET status = 'ACCEPTED', accepted_at = NOW(), updated_at = NOW() WHERE id = $1" + ) + .bind(id) + .execute(&state.pool) + .await; + + let _ = sqlx::query( + "UPDATE leads SET current_acceptances = current_acceptances + 1, updated_at = NOW() WHERE id = $1" + ) + .bind(request.lead_id) + .execute(&state.pool) + .await; + + let _ = sqlx::query( + r#" + UPDATE tracecoin_wallets SET + reserved = reserved - $1, + updated_at = NOW() + WHERE user_id = $2 + "# + ) + .bind(request.tracecoins_reserved as i64) + .bind(user_id) + .execute(&state.pool) + .await; + + if lead_acceptances.0 + 1 >= 10 { + let _ = sqlx::query("UPDATE leads SET status = 'CLOSED', updated_at = NOW() WHERE id = $1") + .bind(request.lead_id) + .execute(&state.pool) + .await; + } + + let _ = sqlx::query( + r#" + INSERT INTO notifications (user_id, title, body, notification_type, reference_id) + VALUES ($1, $2, $3, $4, $5) + "# + ) + .bind(user_id) + .bind("Lead Request Accepted") + .bind("Your lead request has been accepted! Contact details have been shared.") + .bind("LEAD_REQUEST") + .bind(id) + .execute(&state.pool) + .await; + + (StatusCode::OK, Json(serde_json::json!({ + "message": "Lead request accepted successfully", + "contact_details_shared": true + }))).into_response() +} + +async fn reject_lead_request( + State(state): State>, + Path(id): Path, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> impl IntoResponse { + let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + + let request = match sqlx::query_as::<_, LeadRequestRow>( + "SELECT * FROM lead_requests WHERE id = $1 AND status = 'PENDING'" + ) + .bind(id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(r)) => r, + Ok(None) => return (StatusCode::NOT_FOUND, "Lead request not found or already processed").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if request.customer_user_id != user_id { + return (StatusCode::FORBIDDEN, "You are not authorized to reject this request").into_response(); + } + + let _ = sqlx::query( + "UPDATE lead_requests SET status = 'REJECTED', rejected_at = NOW(), rejected_reason = 'Rejected by customer', updated_at = NOW() WHERE id = $1" + ) + .bind(id) + .execute(&state.pool) + .await; + + let _ = sqlx::query( + r#" + UPDATE tracecoin_wallets SET + balance = balance + $1, + reserved = reserved - $1, + updated_at = NOW() + WHERE user_id = $2 + "# + ) + .bind(request.tracecoins_reserved as i64) + .bind(user_id) + .execute(&state.pool) + .await; + + let _ = sqlx::query( + r#" + INSERT INTO notifications (user_id, title, body, notification_type, reference_id) + VALUES ($1, $2, $3, $4, $5) + "# + ) + .bind(user_id) + .bind("Lead Request Rejected") + .bind("Your lead request was not accepted. Tracecoins have been refunded.") + .bind("LEAD_REQUEST") + .bind(id) + .execute(&state.pool) + .await; + + (StatusCode::OK, Json(serde_json::json!({ + "message": "Lead request rejected. Tracecoins refunded.", + "refunded": request.tracecoins_reserved + }))).into_response() +} + +async fn my_requests( + State(state): State>, + Query(q): Query, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> impl IntoResponse { + let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + let offset = (page - 1) * limit; + + let status_filter = q.status + .as_ref() + .map(|s| format!("AND lr.status = '{}'", s)) + .unwrap_or_default(); + + let requests = match sqlx::query_as::<_, LeadRequestRow>(&format!( + r#" + SELECT lr.* FROM lead_requests lr + JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id + WHERE urp.user_id = $1 {} + ORDER BY lr.created_at DESC + LIMIT {} OFFSET {} + "#, + status_filter, limit, offset + )) + .bind(user_id) + .fetch_all(&state.pool) + .await + { + Ok(r) => r, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let requests: Vec = requests.into_iter().map(lead_request_to_response).collect(); + + (StatusCode::OK, Json(serde_json::json!({ + "data": requests, + "pagination": { "page": page, "limit": limit } + }))).into_response() +} + +async fn my_pending_requests( + State(state): State>, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> impl IntoResponse { + let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + + let requests = match sqlx::query_as::<_, LeadRequestRow>( + r#" + SELECT lr.* FROM lead_requests lr + WHERE lr.customer_user_id = $1 AND lr.status = 'PENDING' + ORDER BY lr.expires_at ASC + "# + ) + .bind(user_id) + .fetch_all(&state.pool) + .await + { + Ok(r) => r, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let requests: Vec = requests.into_iter().map(lead_request_to_response).collect(); + + (StatusCode::OK, Json(serde_json::json!({ + "data": requests + }))).into_response() +} + +async fn get_customer_lead_requests( + State(state): State>, + Path(lead_id): Path, + Query(q): Query, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> impl IntoResponse { + let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + let offset = (page - 1) * limit; + + let requests = match sqlx::query_as::<_, LeadRequestRow>(&format!( + r#" + SELECT lr.* FROM lead_requests lr + WHERE lr.lead_id = $1 AND lr.customer_user_id = $2 + ORDER BY lr.created_at DESC + LIMIT {} OFFSET {} + "#, + limit, offset + )) + .bind(lead_id) + .bind(user_id) + .fetch_all(&state.pool) + .await + { + Ok(r) => r, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let requests: Vec = requests.into_iter().map(lead_request_to_response).collect(); + + (StatusCode::OK, Json(serde_json::json!({ + "data": requests, + "pagination": { "page": page, "limit": limit } + }))).into_response() +} diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs index 1260a91..8cb4f89 100644 --- a/apps/leads/src/main.rs +++ b/apps/leads/src/main.rs @@ -11,6 +11,8 @@ use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +pub mod lead_requests; + #[derive(Clone)] pub struct AppState { pub pool: PgPool, @@ -55,7 +57,7 @@ async fn create_lead( INSERT INTO leads (title, description, location, profession_key) VALUES ($1, $2, $3, $4) RETURNING id, title, description, location, profession_key, status, created_at - "#, + "# ) .bind(&payload.title) .bind(&payload.description) @@ -120,6 +122,7 @@ async fn main() { .route("/leads", get(list_leads)) .route("/leads", post(create_lead)) .route("/leads/:id", get(get_lead)) + .nest("/api/lead-requests", lead_requests::router()) .layer(cors) .with_state(state); diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index f7a00a1..6490e9a 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -12,6 +12,8 @@ use uuid::Uuid; use sqlx::postgres::PgPool; use sqlx::FromRow; +pub mod packages; + #[derive(Clone)] struct AppState { beeceptor_url: String, @@ -67,7 +69,8 @@ struct PricingPackageRow { struct PaymentRow { id: Uuid, user_id: Uuid, - tracecoins_credited: i32, + package_id: Option, + tracecoins_credited: Option, } async fn create_order( @@ -77,11 +80,9 @@ async fn create_order( ) -> Result, (StatusCode, String)> { tracing::info!("Creating payment order: amount={}", payload.amount); - // Validate package_id let package_id_str = payload.package_id.as_ref().ok_or((StatusCode::BAD_REQUEST, "package_id is required".to_string()))?; let package_id = Uuid::parse_str(package_id_str).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid package id".to_string()))?; - // Fetch package to get tracecoins amount let package = sqlx::query_as::<_, PricingPackageRow>( "SELECT tracecoins_amount FROM pricing_packages WHERE id = $1 AND is_active = true", ) @@ -93,7 +94,6 @@ async fn create_order( let package = package.ok_or((StatusCode::BAD_REQUEST, "Invalid or inactive package".to_string()))?; let tracecoins_credited = package.tracecoins_amount; - // Call Beeceptor to create order let resp = state .client .post(&state.beeceptor_url) @@ -130,9 +130,11 @@ async fn create_order( .unwrap_or("mock_order_123") .to_string(); - // Insert payment record sqlx::query( - "INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status) VALUES ($1, $2, $3, $4, $5, 'PENDING')", + r#" + INSERT INTO payments (user_id, package_id, razorpay_order_id, amount, tracecoins_credited, status) + VALUES ($1, $2, $3, $4, $5, 'PENDING') + "#, ) .bind(auth.user_id) .bind(package_id) @@ -158,7 +160,6 @@ async fn verify_payment( ) -> Result, (StatusCode, String)> { tracing::info!("Verifying payment: order_id={}", payload.order_id); - // Verify with Beeceptor let verify_url = format!("{}/verify", state.beeceptor_url.trim_end_matches('/')); let resp = state .client @@ -185,9 +186,12 @@ async fn verify_payment( )); } - // Find pending payment by razorpay_order_id let payment = sqlx::query_as::<_, PaymentRow>( - "SELECT id, user_id, tracecoins_credited FROM payments WHERE razorpay_order_id = $1 AND status = 'PENDING'", + r#" + SELECT id, user_id, package_id, tracecoins_credited + FROM payments + WHERE razorpay_order_id = $1 AND status = 'PENDING' + "#, ) .bind(&payload.order_id) .fetch_optional(&state.pool) @@ -199,14 +203,20 @@ async fn verify_payment( None => return Err((StatusCode::NOT_FOUND, "Payment not found or already processed".to_string())), }; - // Ensure the authenticated user matches the payment user if payment.user_id != auth.user_id { return Err((StatusCode::FORBIDDEN, "Payment does not belong to user".to_string())); } - // Update payment status to SUCCESS + let tracecoins = payment.tracecoins_credited.unwrap_or(0); + sqlx::query( - "UPDATE payments SET status = 'SUCCESS', verified_at = NOW(), razorpay_payment_id = $1 WHERE id = $2", + r#" + UPDATE payments SET + status = 'SUCCESS', + razorpay_payment_id = $1, + verified_at = NOW() + WHERE id = $2 + "#, ) .bind(&payload.payment_id) .bind(payment.id) @@ -214,49 +224,50 @@ async fn verify_payment( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - // Credit wallet (increase balance) sqlx::query( - "INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES ($1, $2, 0) ON CONFLICT (user_id) DO UPDATE SET balance = tracecoin_wallets.balance + excluded.balance", + r#" + INSERT INTO tracecoin_wallets (user_id, balance, reserved) + VALUES ($1, $2, 0) + ON CONFLICT (user_id) DO UPDATE SET + balance = tracecoin_wallets.balance + excluded.balance + "#, ) .bind(payment.user_id) - .bind(payment.tracecoins_credited) + .bind(tracecoins as i64) .execute(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - // Get wallet id for ledger - match sqlx::query_scalar::<_, Uuid>( - "SELECT id FROM tracecoin_wallets WHERE user_id = $1", + if let Ok(Some(wallet_id)) = sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM tracecoin_wallets WHERE user_id = $1" ) .bind(payment.user_id) .fetch_optional(&state.pool) .await { - Ok(Some(wallet_id)) => { - sqlx::query( - "INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id) VALUES ($1, 'CREDIT', $2, $3, $4)", - ) - .bind(wallet_id) - .bind(payment.tracecoins_credited as i64) - .bind("PURCHASE") - .bind(payment.id) - .execute(&state.pool) - .await - .ok(); - } - _ => {} - } + sqlx::query( + r#" + INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, balance_after, reference_type, reference_id, description) + VALUES ($1, 'CREDIT', $2, $2, 'PAYMENT', $3, 'Package purchase') + "#, + ) + .bind(wallet_id) + .bind(tracecoins as i64) + .bind(payment.id) + .execute(&state.pool) + .await + .ok(); + } - // Send notification to user about successful purchase let _ = sqlx::query( r#" - INSERT INTO notifications (user_id, title, body, type, reference_id) + INSERT INTO notifications (user_id, title, body, notification_type, reference_id) VALUES ($1, $2, $3, $4, $5) "#, ) .bind(payment.user_id) .bind("Tracecoins Purchased Successfully") - .bind(format!("Your {} Tracecoin package has been credited to your wallet.", payment.tracecoins_credited)) + .bind(format!("Your {} Tracecoin package has been credited to your wallet.", tracecoins)) .bind("PAYMENT") .bind(payment.id) .execute(&state.pool) @@ -348,6 +359,7 @@ async fn main() { .route("/api/payments/create-order", post(create_order)) .route("/api/payments/verify", post(verify_payment)) .route("/api/payments/{id}/status", get(get_payment_status)) + .nest("/api/packages", packages::router()) .with_state(state); let port: u16 = std::env::var("PORT") @@ -356,7 +368,7 @@ async fn main() { .expect("PORT must be a valid u16"); let addr = SocketAddr::from(([0, 0, 0, 0], port)); - tracing::info!("Payments service (mock via Beeceptor) listening on {}", addr); + tracing::info!("Payments service listening on {}", addr); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); diff --git a/apps/payments/src/packages.rs b/apps/payments/src/packages.rs new file mode 100644 index 0000000..0738001 --- /dev/null +++ b/apps/payments/src/packages.rs @@ -0,0 +1,418 @@ +use crate::AppState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, patch, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct PackageTypeQuery { + pub package_type: Option, + pub applicable_role: Option, + pub active_only: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PaginationQuery { + pub page: Option, + pub limit: Option, + pub search: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreatePackageRequest { + pub name: String, + pub description: Option, + pub package_type: String, + pub applicable_roles: Vec, + pub tracecoins_amount: i32, + pub price: i32, + pub duration_days: Option, + pub valid_from: Option>, + pub valid_until: Option>, + pub is_promotional: Option, + pub is_active: Option, + pub features: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdatePackageRequest { + pub name: Option, + pub description: Option, + pub tracecoins_amount: Option, + pub price: Option, + pub duration_days: Option, + pub valid_from: Option>, + pub valid_until: Option>, + pub is_promotional: Option, + pub is_active: Option, + pub features: Option, +} + +#[derive(Debug, FromRow)] +pub struct PricingPackageRow { + pub id: Uuid, + pub name: String, + pub description: Option, + pub package_type: String, + pub applicable_roles: Vec, + pub tracecoins_amount: i32, + pub price: i32, + pub duration_days: Option, + pub valid_from: Option>, + pub valid_until: Option>, + pub is_promotional: bool, + pub is_active: bool, + pub features: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct PricingPackageResponse { + pub id: Uuid, + pub name: String, + pub description: Option, + pub package_type: String, + pub applicable_roles: Vec, + pub tracecoins_amount: i32, + pub price: i32, + pub duration_days: Option, + pub valid_from: Option>, + pub valid_until: Option>, + pub is_promotional: bool, + pub is_active: bool, + pub features: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub is_available: bool, + pub is_expired: bool, +} + +impl From for PricingPackageResponse { + fn from(row: PricingPackageRow) -> Self { + let now = chrono::Utc::now(); + let is_expired = row.valid_until.map(|v| v < now).unwrap_or(false); + let is_not_started = row.valid_from.map(|v| v > now).unwrap_or(false); + let is_available = row.is_active && !is_expired && !is_not_started; + + PricingPackageResponse { + id: row.id, + name: row.name, + description: row.description, + package_type: row.package_type, + applicable_roles: row.applicable_roles, + tracecoins_amount: row.tracecoins_amount, + price: row.price, + duration_days: row.duration_days, + valid_from: row.valid_from, + valid_until: row.valid_until, + is_promotional: row.is_promotional, + is_active: row.is_active, + features: row.features, + created_at: row.created_at, + updated_at: row.updated_at, + is_available, + is_expired, + } + } +} + +pub fn router() -> Router { + Router::new() + .route("/", get(list_packages)) + .route("/", post(create_package)) + .route("/{id}", get(get_package)) + .route("/{id}", patch(update_package)) + .route("/{id}", delete(delete_package)) + .route("/by-type", get(get_packages_by_type)) + .route("/for-role", get(get_packages_for_role)) +} + +async fn list_packages( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20).min(100); + let offset = (page - 1) * limit; + + let search_filter = q.search + .as_ref() + .map(|s| format!("AND (name ILIKE '%{}%' OR description ILIKE '%{}%')", s.replace('\'', "''"), s.replace('\'', "''"))) + .unwrap_or_default(); + + let packages = sqlx::query_as::<_, PricingPackageRow>( + &format!( + r#" + SELECT id, name, description, package_type, applicable_roles, + tracecoins_amount, price, duration_days, valid_from, valid_until, + is_promotional, is_active, features, created_at, updated_at + FROM pricing_packages + WHERE 1=1 {} + ORDER BY created_at DESC + LIMIT {} OFFSET {} + "#, + search_filter, limit, offset + ) + ) + .fetch_all(&state.pool) + .await; + + let packages = match packages { + Ok(p) => p, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let total: (i64,) = match sqlx::query_as( + &format!( + "SELECT COUNT(*) FROM pricing_packages WHERE 1=1 {}", + search_filter + ) + ) + .fetch_one(&state.pool) + .await + { + Ok(t) => t, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let packages: Vec = packages.into_iter().map(|p| p.into()).collect(); + + (StatusCode::OK, Json(serde_json::json!({ + "data": packages, + "pagination": { + "page": page, + "limit": limit, + "total": total.0, + "pages": (total.0 as f64 / limit as f64).ceil() as i64 + } + }))).into_response() +} + +async fn get_package( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match sqlx::query_as::<_, PricingPackageRow>( + r#" + SELECT id, name, description, package_type, applicable_roles, + tracecoins_amount, price, duration_days, valid_from, valid_until, + is_promotional, is_active, features, created_at, updated_at + FROM pricing_packages WHERE id = $1 + "# + ) + .bind(id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(pkg)) => { + let response: PricingPackageResponse = pkg.into(); + (StatusCode::OK, Json(response)).into_response() + } + Ok(None) => (StatusCode::NOT_FOUND, "Package not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn create_package( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let result = sqlx::query_as::<_, PricingPackageRow>( + r#" + INSERT INTO pricing_packages (name, description, package_type, applicable_roles, + tracecoins_amount, price, duration_days, valid_from, valid_until, + is_promotional, is_active, features) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id, name, description, package_type, applicable_roles, + tracecoins_amount, price, duration_days, valid_from, valid_until, + is_promotional, is_active, features, created_at, updated_at + "# + ) + .bind(&payload.name) + .bind(&payload.description) + .bind(&payload.package_type) + .bind(&payload.applicable_roles) + .bind(payload.tracecoins_amount) + .bind(payload.price) + .bind(payload.duration_days) + .bind(payload.valid_from) + .bind(payload.valid_until) + .bind(payload.is_promotional.unwrap_or(false)) + .bind(payload.is_active.unwrap_or(true)) + .bind(payload.features) + .fetch_one(&state.pool) + .await; + + match result { + Ok(pkg) => { + let response: PricingPackageResponse = pkg.into(); + (StatusCode::CREATED, Json(response)).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn update_package( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + let existing = sqlx::query_as::<_, PricingPackageRow>( + "SELECT * FROM pricing_packages WHERE id = $1" + ) + .bind(id) + .fetch_optional(&state.pool) + .await; + + let existing = match existing { + Ok(Some(e)) => e, + Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let updated = sqlx::query_as::<_, PricingPackageRow>( + r#" + UPDATE pricing_packages SET + name = COALESCE($2, name), + description = COALESCE($3, description), + tracecoins_amount = COALESCE($4, tracecoins_amount), + price = COALESCE($5, price), + duration_days = COALESCE($6, duration_days), + valid_from = COALESCE($7, valid_from), + valid_until = COALESCE($8, valid_until), + is_promotional = COALESCE($9, is_promotional), + is_active = COALESCE($10, is_active), + features = COALESCE($11, features), + updated_at = NOW() + WHERE id = $1 + RETURNING id, name, description, package_type, applicable_roles, + tracecoins_amount, price, duration_days, valid_from, valid_until, + is_promotional, is_active, features, created_at, updated_at + "# + ) + .bind(id) + .bind(&payload.name) + .bind(&payload.description) + .bind(payload.tracecoins_amount) + .bind(payload.price) + .bind(payload.duration_days) + .bind(payload.valid_from) + .bind(payload.valid_until) + .bind(payload.is_promotional) + .bind(payload.is_active) + .bind(payload.features) + .fetch_one(&state.pool) + .await; + + match updated { + Ok(pkg) => { + let response: PricingPackageResponse = pkg.into(); + (StatusCode::OK, Json(response)).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn delete_package( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match sqlx::query("DELETE FROM pricing_packages WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await + { + Ok(r) if r.rows_affected() > 0 => { + (StatusCode::OK, Json(serde_json::json!({"message": "Package deleted"}))).into_response() + } + Ok(_) => (StatusCode::NOT_FOUND, "Package not found").into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn get_packages_by_type( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let package_type = q.package_type.as_deref().unwrap_or("TRACECOIN_BUNDLE"); + let now = chrono::Utc::now(); + + let packages = sqlx::query_as::<_, PricingPackageRow>( + r#" + SELECT id, name, description, package_type, applicable_roles, + tracecoins_amount, price, duration_days, valid_from, valid_until, + is_promotional, is_active, features, created_at, updated_at + FROM pricing_packages + WHERE package_type = $1 + AND is_active = true + AND (valid_from IS NULL OR valid_from <= $2) + AND (valid_until IS NULL OR valid_until > $2) + ORDER BY is_promotional DESC, price ASC + "# + ) + .bind(package_type) + .bind(now) + .fetch_all(&state.pool) + .await; + + let packages = match packages { + Ok(p) => p, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let packages: Vec = packages.into_iter().map(|p| p.into()).collect(); + + (StatusCode::OK, Json(serde_json::json!({ + "data": packages, + "package_type": package_type + }))).into_response() +} + +async fn get_packages_for_role( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let applicable_role = q.applicable_role.as_deref().unwrap_or(""); + let active_only = q.active_only.unwrap_or(true); + let now = chrono::Utc::now(); + + let packages = sqlx::query_as::<_, PricingPackageRow>( + &format!( + r#" + SELECT id, name, description, package_type, applicable_roles, + tracecoins_amount, price, duration_days, valid_from, valid_until, + is_promotional, is_active, features, created_at, updated_at + FROM pricing_packages + WHERE ($1 = '' OR $1 = ANY(applicable_roles)) + AND (is_active = true OR {} = false) + AND (valid_from IS NULL OR valid_from <= $2) + AND (valid_until IS NULL OR valid_until > $2) + ORDER BY is_promotional DESC, price ASC + "#, + if active_only { "true" } else { "false" } + ) + ) + .bind(applicable_role) + .bind(now) + .fetch_all(&state.pool) + .await; + + let packages = match packages { + Ok(p) => p, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let packages: Vec = packages.into_iter().map(|p| p.into()).collect(); + + (StatusCode::OK, Json(serde_json::json!({ + "data": packages, + "applicable_role": applicable_role + }))).into_response() +} diff --git a/crates/db/migrations/20260317190200_requirements_and_leads.up.sql b/crates/db/migrations/20260317190200_requirements_and_leads.up.sql.skip similarity index 100% rename from crates/db/migrations/20260317190200_requirements_and_leads.up.sql rename to crates/db/migrations/20260317190200_requirements_and_leads.up.sql.skip diff --git a/crates/db/migrations/20260415000000_complete_migration.up.sql b/crates/db/migrations/20260415000000_complete_migration.up.sql index 73d9bdc..f6987fe 100644 --- a/crates/db/migrations/20260415000000_complete_migration.up.sql +++ b/crates/db/migrations/20260415000000_complete_migration.up.sql @@ -125,10 +125,10 @@ SELECT gen_random_uuid(), p.user_id, 'ugc_content_creator', p.display_name, p.bi FROM ugc_content_creator_profiles p WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'ugc_content_creator'); --- Backfill from company_profiles +-- Backfill from company_profiles (companies don't have bio) INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, created_at, updated_at) -SELECT gen_random_uuid(), cp.user_id, 'company', cp.company_name, cp.bio, NULL, - COALESCE(cp.status, 'ACTIVE'), cp.created_at, COALESCE(cp.updated_at, NOW()) +SELECT gen_random_uuid(), cp.user_id, 'company', cp.company_name, NULL, NULL, + 'ACTIVE', cp.created_at, COALESCE(cp.updated_at, NOW()) FROM company_profiles cp WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = cp.user_id AND urp.role_key = 'company'); diff --git a/crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql b/crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql.skip similarity index 100% rename from crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql rename to crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql.skip diff --git a/crates/db/migrations_new/001_minimal_setup.up.sql b/crates/db/migrations_new/001_minimal_setup.up.sql new file mode 100644 index 0000000..4ee67be --- /dev/null +++ b/crates/db/migrations_new/001_minimal_setup.up.sql @@ -0,0 +1,726 @@ +-- Minimal setup migration - creates essential tables needed by complete migration +BEGIN; + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + phone VARCHAR(50), + password_hash TEXT, + full_name VARCHAR(255), + account_type VARCHAR(50) DEFAULT 'USER', + email_verified BOOLEAN DEFAULT false, + phone_verified BOOLEAN DEFAULT false, + status VARCHAR(50) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); + +-- Roles table +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + code VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + can_approve_requests BOOLEAN DEFAULT false, + can_manage_system_settings BOOLEAN DEFAULT false, + audience VARCHAR(50) DEFAULT 'USER', + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- User roles table +CREATE TABLE IF NOT EXISTS user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Role permissions table +CREATE TABLE IF NOT EXISTS role_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Departments table +CREATE TABLE IF NOT EXISTS departments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + code VARCHAR(50), + description TEXT, + department_head UUID REFERENCES users(id), + department_email VARCHAR(255), + visibility VARCHAR(50) DEFAULT 'VISIBLE', + transfers_enabled BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Designations table +CREATE TABLE IF NOT EXISTS designations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + code VARCHAR(50), + department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + description TEXT, + level INTEGER DEFAULT 1, + can_manage_team BOOLEAN DEFAULT false, + can_approve BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Employees table +CREATE TABLE IF NOT EXISTS employees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + designation_id UUID REFERENCES designations(id) ON DELETE SET NULL, + status VARCHAR(50) DEFAULT 'ACTIVE', + joining_date DATE, + employment_status VARCHAR(50), + manager_employee_id UUID REFERENCES employees(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Refresh tokens table +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Notification preferences table +CREATE TABLE IF NOT EXISTS notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + email_enabled BOOLEAN DEFAULT true, + push_enabled BOOLEAN DEFAULT true, + sms_enabled BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Profile extension tables (with user_role_profile_id for new schema) +CREATE TABLE IF NOT EXISTS photographer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + equipment_list TEXT, + years_of_experience INTEGER, + hourly_rate INTEGER, + specialties TEXT[] DEFAULT '{}', + camera_brands TEXT[] DEFAULT '{}', + studio_available BOOLEAN DEFAULT false, + outdoor_shoots BOOLEAN DEFAULT true, + travel_radius_km INTEGER DEFAULT 50, + starting_price_inr INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tutor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + subjects TEXT[] DEFAULT '{}', + board_types TEXT[] DEFAULT '{}', + qualification VARCHAR(255), + teaches_online BOOLEAN DEFAULT true, + teaches_offline BOOLEAN DEFAULT true, + experience_years INTEGER DEFAULT 0, + hourly_rate_inr INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + specialties TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + willing_to_travel BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS developer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + skills TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + preferred_roles TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS video_editor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + software_expertise TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + portfolio_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + design_specialties TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + platforms_managed TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + certifications TEXT[] DEFAULT '{}', + specializations TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + offers_online_sessions BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS catering_service_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + cuisine_types TEXT[] DEFAULT '{}', + min_order_amount INTEGER DEFAULT 0, + experience_years INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ugc_content_creator_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + platforms TEXT[] DEFAULT '{}', + follower_count INTEGER DEFAULT 0, + niche TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Company profiles +CREATE TABLE IF NOT EXISTS company_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + company_name VARCHAR(255) NOT NULL, + registration_number VARCHAR(100), + industry VARCHAR(150), + website_url VARCHAR(255), + employee_count INTEGER, + business_type VARCHAR(100), + gst_number VARCHAR(50), + contact_name VARCHAR(255), + contact_email VARCHAR(255), + contact_phone VARCHAR(50), + address_line1 TEXT, + city VARCHAR(100), + state VARCHAR(100), + country VARCHAR(100) DEFAULT 'India', + postal_code VARCHAR(20), + status VARCHAR(50) DEFAULT 'ACTIVE', + free_job_slots INTEGER DEFAULT 3, + purchased_job_slots INTEGER DEFAULT 0, + free_contact_views INTEGER DEFAULT 10, + purchased_contact_views INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id) +); + +-- Customer profiles +CREATE TABLE IF NOT EXISTS customer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + full_name VARCHAR(255), + phone VARCHAR(50), + city VARCHAR(100), + area VARCHAR(100), + preferred_professions TEXT[] DEFAULT '{}', + active_requirement_count INTEGER DEFAULT 0, + status VARCHAR(50) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id) +); + +-- Job seeker profiles +CREATE TABLE IF NOT EXISTS job_seeker_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + full_name VARCHAR(255), + location VARCHAR(255), + summary TEXT, + experience_years INTEGER DEFAULT 0, + skills TEXT[] DEFAULT '{}', + resume_url TEXT, + active_application_count INTEGER DEFAULT 0, + status VARCHAR(50) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id) +); + +-- Jobs table +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID REFERENCES company_profiles(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + requirements TEXT, + location VARCHAR(255), + job_type VARCHAR(50), + experience_min INTEGER DEFAULT 0, + experience_max INTEGER DEFAULT 0, + salary_min INTEGER, + salary_max INTEGER, + status VARCHAR(50) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Job applications table +CREATE TABLE IF NOT EXISTS job_applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + applicant_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + cover_letter TEXT, + resume_url TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create user_role_profiles first (needed by leads table) +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_status ON user_role_profiles(status); + +-- Leads table (renamed from requirements) +CREATE TABLE IF NOT EXISTS leads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + customer_id UUID REFERENCES users(id), + profession_key VARCHAR(50), + title VARCHAR(255) NOT NULL, + description TEXT, + location VARCHAR(255), + budget_min INTEGER, + budget_max INTEGER, + urgency VARCHAR(50) DEFAULT 'NORMAL', + status VARCHAR(50) DEFAULT 'OPEN', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Other essential tables +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + notification_type VARCHAR(50), + reference_id UUID, + is_read BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID NOT NULL REFERENCES user_role_profiles(id), + reviewer_user_id UUID NOT NULL REFERENCES users(id), + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + status VARCHAR(50) DEFAULT 'VISIBLE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tracecoin_wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + balance BIGINT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tracecoin_ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES tracecoin_wallets(id), + transaction_type VARCHAR(50) NOT NULL, + amount BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + reference_type VARCHAR(50), + reference_id UUID, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + service_name VARCHAR(255) NOT NULL, + description TEXT, + price INTEGER, + price_type VARCHAR(50) DEFAULT 'FIXED', + duration_minutes INTEGER, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS pricing_packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + name VARCHAR(255) NOT NULL, + description TEXT, + price INTEGER NOT NULL, + duration_days INTEGER, + features JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coupons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + discount_type VARCHAR(20) NOT NULL, + discount_value INTEGER NOT NULL, + min_order_amount INTEGER DEFAULT 0, + max_uses INTEGER, + used_count INTEGER DEFAULT 0, + valid_from TIMESTAMPTZ, + valid_until TIMESTAMPTZ, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coupon_redemptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + coupon_id UUID NOT NULL REFERENCES coupons(id), + user_id UUID NOT NULL REFERENCES users(id), + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS discounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + package_id UUID NOT NULL REFERENCES pricing_packages(id), + discount_type VARCHAR(20) NOT NULL, + discount_value INTEGER NOT NULL, + valid_from TIMESTAMPTZ, + valid_until TIMESTAMPTZ, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + amount INTEGER NOT NULL, + currency VARCHAR(10) DEFAULT 'INR', + payment_method VARCHAR(50), + payment_status VARCHAR(50) DEFAULT 'PENDING', + reference_id VARCHAR(255), + transaction_id VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + invoice_number VARCHAR(50) NOT NULL UNIQUE, + amount INTEGER NOT NULL, + status VARCHAR(50) DEFAULT 'DRAFT', + issued_at TIMESTAMPTZ, + due_at TIMESTAMPTZ, + paid_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS portfolio_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + title VARCHAR(255) NOT NULL, + description TEXT, + media_url TEXT, + media_type VARCHAR(50), + display_order INTEGER DEFAULT 0, + is_featured BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + verification_type VARCHAR(50) NOT NULL DEFAULT 'IDENTITY', + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_url TEXT NOT NULL, + file_name TEXT, + mime_type TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID NOT NULL REFERENCES user_role_profiles(id), + verification_type VARCHAR(50) NOT NULL, + document_url TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + verified_at TIMESTAMPTZ, + verified_by UUID REFERENCES users(id), + rejection_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + theme VARCHAR(20) DEFAULT 'LIGHT', + language VARCHAR(10) DEFAULT 'en', + timezone VARCHAR(50) DEFAULT 'Asia/Kolkata', + email_notifications BOOLEAN DEFAULT true, + push_notifications BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS account_deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reason TEXT, + scheduled_deletion_at TIMESTAMPTZ, + status VARCHAR(50) DEFAULT 'PENDING', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS activity_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50), + entity_id UUID, + metadata JSONB, + ip_address TEXT, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS email_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + email_type VARCHAR(50) NOT NULL, + recipient_email VARCHAR(255) NOT NULL, + subject VARCHAR(255), + status VARCHAR(50) DEFAULT 'SENT', + error_message TEXT, + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- KB tables +CREATE TABLE IF NOT EXISTS kb_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + display_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS kb_articles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID REFERENCES kb_categories(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + content TEXT, + summary TEXT, + tags TEXT[], + views_count INTEGER DEFAULT 0, + is_published BOOLEAN DEFAULT false, + is_featured BOOLEAN DEFAULT false, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Support tables +CREATE TABLE IF NOT EXISTS support_tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + subject VARCHAR(255) NOT NULL, + description TEXT, + priority VARCHAR(20) DEFAULT 'MEDIUM', + status VARCHAR(50) DEFAULT 'OPEN', + assigned_to UUID REFERENCES users(id), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS support_ticket_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id), + message TEXT NOT NULL, + is_internal BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Config tables +CREATE TABLE IF NOT EXISTS runtime_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + config_key VARCHAR(100) NOT NULL UNIQUE, + config_value JSONB NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS onboarding_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_key VARCHAR(50) NOT NULL, + steps JSONB NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS dashboard_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_key VARCHAR(50) NOT NULL, + audience VARCHAR(50) DEFAULT 'USER', + widgets JSONB NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_status ON user_roles(status); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read); +CREATE INDEX IF NOT EXISTS idx_reviews_user_role_profile_id ON reviews(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_services_user_role_profile_id ON services(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_pricing_packages_user_role_profile_id ON pricing_packages(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_portfolio_items_user_role_profile_id ON portfolio_items(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); +CREATE INDEX IF NOT EXISTS idx_jobs_company_id ON jobs(company_id); +CREATE INDEX IF NOT EXISTS idx_job_applications_job_id ON job_applications(job_id); +CREATE INDEX IF NOT EXISTS idx_job_applications_applicant ON job_applications(applicant_user_id); +CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status); +CREATE INDEX IF NOT EXISTS idx_leads_user_role_profile_id ON leads(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_id ON tracecoin_ledger(wallet_id); +CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code); +CREATE INDEX IF NOT EXISTS idx_support_tickets_user_id ON support_tickets(user_id); +CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status); +CREATE INDEX IF NOT EXISTS idx_activity_logs_user_id ON activity_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_email_logs_user_id ON email_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_email_logs_sent_at ON email_logs(sent_at); +CREATE INDEX IF NOT EXISTS idx_kb_articles_category_id ON kb_articles(category_id); +CREATE INDEX IF NOT EXISTS idx_kb_articles_slug ON kb_articles(slug); +CREATE INDEX IF NOT EXISTS idx_verification_requests_user_role_profile_id ON verification_requests(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_verifications_user_role_profile_id ON verifications(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id); + +-- Insert default roles +INSERT INTO roles (id, name, code, description, audience, is_active) VALUES + ('00000000-0000-0000-0000-000000000001', 'Super Admin', 'SUPER_ADMIN', 'Full system access', 'ADMIN', true), + ('00000000-0000-0000-0000-000000000002', 'Admin', 'ADMIN', 'Administrative access', 'ADMIN', true), + ('00000000-0000-0000-0000-000000000003', 'User', 'USER', 'Regular user', 'USER', true), + ('00000000-0000-0000-0000-000000000004', 'Company', 'COMPANY', 'Company account', 'BUSINESS', true), + ('00000000-0000-0000-0000-000000000005', 'Customer', 'CUSTOMER', 'Customer account', 'USER', true) +ON CONFLICT (code) DO NOTHING; + +COMMIT; diff --git a/crates/db/migrations_new/002_pricing_and_leads.up.sql b/crates/db/migrations_new/002_pricing_and_leads.up.sql new file mode 100644 index 0000000..4e5e494 --- /dev/null +++ b/crates/db/migrations_new/002_pricing_and_leads.up.sql @@ -0,0 +1,285 @@ +-- ============================================================================ +-- MIGRATION: Complete Schema Updates for Pricing & Lead Requests +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- 1. Update pricing_packages table with new columns +-- ============================================================================ + +ALTER TABLE pricing_packages + DROP COLUMN IF EXISTS user_role_profile_id, + ADD COLUMN IF NOT EXISTS package_type VARCHAR(50) NOT NULL DEFAULT 'TRACECOIN_BUNDLE', + ADD COLUMN IF NOT EXISTS applicable_roles TEXT[] DEFAULT '{}', + ADD COLUMN IF NOT EXISTS tracecoins_amount INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS valid_until TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS is_promotional BOOLEAN DEFAULT false; + +-- Add index for package lookup +CREATE INDEX IF NOT EXISTS idx_pricing_packages_type ON pricing_packages(package_type); + +-- ============================================================================ +-- 2. Update payments table with new columns +-- ============================================================================ + +ALTER TABLE payments + ADD COLUMN IF NOT EXISTS package_id UUID, + ADD COLUMN IF NOT EXISTS razorpay_order_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS razorpay_payment_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS tracecoins_credited INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ; + +-- Add foreign key if not exists +ALTER TABLE payments + ADD CONSTRAINT payments_package_id_fkey + FOREIGN KEY (package_id) REFERENCES pricing_packages(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_payments_package_id ON payments(package_id); +CREATE INDEX IF NOT EXISTS idx_payments_razorpay_order_id ON payments(razorpay_order_id); + +-- ============================================================================ +-- 3. Create lead_requests table +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS lead_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE, + user_role_profile_id UUID NOT NULL REFERENCES user_role_profiles(id) ON DELETE CASCADE, + customer_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + tracecoins_reserved INTEGER NOT NULL DEFAULT 25, + message TEXT, + expires_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + rejected_at TIMESTAMPTZ, + rejected_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_lead_requests_lead_id ON lead_requests(lead_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_user_role_profile_id ON lead_requests(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_customer_user_id ON lead_requests(customer_user_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status); +CREATE INDEX IF NOT EXISTS idx_lead_requests_expires_at ON lead_requests(expires_at); + +-- ============================================================================ +-- 4. Add display_code to company_profiles +-- ============================================================================ + +ALTER TABLE company_profiles + ADD COLUMN IF NOT EXISTS display_code VARCHAR(20) UNIQUE; + +-- ============================================================================ +-- 5. Add display_code to customer_profiles +-- ============================================================================ + +ALTER TABLE customer_profiles + ADD COLUMN IF NOT EXISTS display_code VARCHAR(20) UNIQUE; + +-- ============================================================================ +-- 6. Add display_code to job_seeker_profiles +-- ============================================================================ + +ALTER TABLE job_seeker_profiles + ADD COLUMN IF NOT EXISTS display_code VARCHAR(20) UNIQUE; + +-- ============================================================================ +-- 7. Add free_requirement_slots and purchased_requirement_slots to customer_profiles +-- ============================================================================ + +ALTER TABLE customer_profiles + ADD COLUMN IF NOT EXISTS free_requirement_slots INTEGER DEFAULT 2, + ADD COLUMN IF NOT EXISTS purchased_requirement_slots INTEGER DEFAULT 0; + +-- ============================================================================ +-- 8. Update leads table with new columns +-- ============================================================================ + +ALTER TABLE leads + ADD COLUMN IF NOT EXISTS customer_user_id UUID REFERENCES users(id), + ADD COLUMN IF NOT EXISTS max_acceptances INTEGER DEFAULT 10, + ADD COLUMN IF NOT EXISTS current_acceptances INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS cover_image_url TEXT; + +-- ============================================================================ +-- 9. Add tracecoins_reserved column to lead_requests for proper tracking +-- ============================================================================ + +-- Already added in step 3 + +-- ============================================================================ +-- 10. Create function to auto-generate display codes +-- ============================================================================ + +CREATE OR REPLACE FUNCTION generate_display_code(prefix VARCHAR(10)) +RETURNS VARCHAR(20) AS $$ +DECLARE + new_code VARCHAR(20); + seq_num INTEGER; +BEGIN + -- Get the next sequence number for this prefix + SELECT COALESCE( + (SELECT MAX(CAST(SUBSTRING(code FROM 4) AS INTEGER)) + 1 + FROM ( + SELECT display_code as code FROM company_profiles WHERE display_code LIKE prefix || '%' + UNION ALL + SELECT display_code as code FROM customer_profiles WHERE display_code LIKE prefix || '%' + UNION ALL + SELECT display_code as code FROM job_seeker_profiles WHERE display_code LIKE prefix || '%' + ) all_codes + ), + 1 + ) INTO seq_num; + + new_code := prefix || LPAD(seq_num::TEXT, 4, '0'); + RETURN new_code; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- 11. Create indexes for performance +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_leads_customer_user_id ON leads(customer_user_id); +CREATE INDEX IF NOT EXISTS idx_leads_status_expires ON leads(status, expires_at); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); + +-- ============================================================================ +-- 12. Create tracecoin ledger entry types enum support +-- ============================================================================ + +-- Add transaction_type values for ledger tracking +-- The ledger already has 'type' column, ensure we have proper entries + +-- ============================================================================ +-- 13. Add indexes for verification queries +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_verifications_user_role_profile_id ON verifications(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_verifications_status ON verifications(status); +CREATE INDEX IF NOT EXISTS idx_verification_requests_user_role_profile_id ON verification_requests(user_role_profile_id); + +-- ============================================================================ +-- 14. Add tracecoin refund support to ledger +-- ============================================================================ + +-- Reserve tracecoins for lead request +CREATE OR REPLACE FUNCTION reserve_tracecoins_for_lead_request( + p_user_id UUID, + p_amount INTEGER, + p_lead_request_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_wallet_id UUID; + v_balance BIGINT; +BEGIN + -- Get wallet + SELECT id, balance INTO v_wallet_id, v_balance + FROM tracecoin_wallets + WHERE user_id = p_user_id + FOR UPDATE; + + IF v_wallet_id IS NULL THEN + RETURN FALSE; + END IF; + + IF v_balance < p_amount THEN + RETURN FALSE; + END IF; + + -- Deduct from balance (reserved, not yet spent) + UPDATE tracecoin_wallets + SET balance = balance - p_amount, + reserved = COALESCE(reserved, 0) + p_amount, + updated_at = NOW() + WHERE id = v_wallet_id; + + -- Add ledger entry + INSERT INTO tracecoin_ledger (wallet_id, type, amount, balance_after, reference_type, reference_id, reason) + VALUES (v_wallet_id, 'RESERVE', -p_amount, v_balance - p_amount, 'LEAD_REQUEST', p_lead_request_id, 'Lead request reservation'); + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +-- Confirm tracecoins (when customer accepts) +CREATE OR REPLACE FUNCTION confirm_tracecoins_for_lead( + p_user_id UUID, + p_amount INTEGER, + p_lead_request_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_wallet_id UUID; + v_balance BIGINT; +BEGIN + -- Get wallet + SELECT id, balance, COALESCE(reserved, 0) INTO v_wallet_id, v_balance, v_balance + FROM tracecoin_wallets + WHERE user_id = p_user_id + FOR UPDATE; + + IF v_wallet_id IS NULL THEN + RETURN FALSE; + END IF; + + -- Move from reserved to spent + UPDATE tracecoin_wallets + SET reserved = reserved - p_amount, + updated_at = NOW() + WHERE id = v_wallet_id; + + -- Add ledger entry for confirmation + INSERT INTO tracecoin_ledger (wallet_id, type, amount, balance_after, reference_type, reference_id, reason) + VALUES (v_wallet_id, 'SPEND', -p_amount, v_balance, 'LEAD_REQUEST', p_lead_request_id, 'Lead request accepted'); + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +-- Release tracecoins (when customer rejects or request expires) +CREATE OR REPLACE FUNCTION release_tracecoins_for_lead( + p_user_id UUID, + p_amount INTEGER, + p_lead_request_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_wallet_id UUID; + v_balance BIGINT; +BEGIN + -- Get wallet + SELECT id, balance INTO v_wallet_id, v_balance + FROM tracecoin_wallets + WHERE user_id = p_user_id + FOR UPDATE; + + IF v_wallet_id IS NULL THEN + RETURN FALSE; + END IF; + + -- Return to available balance from reserved + UPDATE tracecoin_wallets + SET balance = balance + p_amount, + reserved = reserved - p_amount, + updated_at = NOW() + WHERE id = v_wallet_id; + + -- Add ledger entry for release + INSERT INTO tracecoin_ledger (wallet_id, type, amount, balance_after, reference_type, reference_id, reason) + VALUES (v_wallet_id, 'RELEASE', p_amount, v_balance + p_amount, 'LEAD_REQUEST', p_lead_request_id, 'Lead request rejected/expired'); + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- 15. Update tracecoin_wallets to have reserved column +-- ============================================================================ + +ALTER TABLE tracecoin_wallets + ADD COLUMN IF NOT EXISTS reserved BIGINT DEFAULT 0; + +COMMIT; diff --git a/crates/db/migrations_new/20260415000000_complete_migration.up.sql.skip b/crates/db/migrations_new/20260415000000_complete_migration.up.sql.skip new file mode 100644 index 0000000..ae0672c --- /dev/null +++ b/crates/db/migrations_new/20260415000000_complete_migration.up.sql.skip @@ -0,0 +1,618 @@ +-- ============================================================================ +-- Nxtgauge Database Complete Migration +-- Version: 20260415000000 +-- This migration performs a COMPLETE schema transformation +-- NO FALLBACKS - This is a one-way migration +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- PHASE 1: Create New Core Tables +-- ============================================================================ + +-- 1.1 user_sessions (new) +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); + +-- 1.2 user_role_profiles (NEW ROOT - CRITICAL) +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_status ON user_role_profiles(status); + +-- ============================================================================ +-- PHASE 2: Skip backfill for fresh database +-- ============================================================================ +-- (No data to backfill from - profile tables use user_role_profile_id directly) + +-- ============================================================================ +-- PHASE 3: Update Extension Tables to Use user_role_profile_id +-- ============================================================================ + +-- Add user_role_profile_id column to ALL extension tables (already exist in fresh schema) + +-- ============================================================================ +-- PHASE 5: Update Portfolio Tables +-- ============================================================================ + +-- Add user_role_profile_id to portfolio_items +ALTER TABLE portfolio_items ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE portfolio_items ADD COLUMN IF NOT EXISTS display_order INTEGER DEFAULT 0; + +-- Backfill portfolio_items from professionals +UPDATE portfolio_items pi SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE pi.professional_id IS NOT NULL + AND urp.user_id = (SELECT user_id FROM professionals p WHERE p.id = pi.professional_id); + +-- Update remaining using user_id +UPDATE portfolio_items pi SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE pi.user_id IS NOT NULL AND pi.user_role_profile_id IS NULL + AND EXISTS (SELECT 1 FROM user_role_profiles urp2 WHERE urp2.user_id = pi.user_id AND urp2.role_key = pi.profession_key); + +-- Add user_role_profile_id to services +ALTER TABLE services ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; + +-- Backfill services +UPDATE services s SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE s.professional_id IS NOT NULL + AND urp.user_id = (SELECT user_id FROM professionals p WHERE p.id = s.professional_id); + +UPDATE services s SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE s.user_id IS NOT NULL AND s.user_role_profile_id IS NULL + AND EXISTS (SELECT 1 FROM user_role_profiles urp2 WHERE urp2.user_id = s.user_id AND urp2.role_key = s.profession_key); + +-- ============================================================================ +-- PHASE 6: Rename Tables +-- ============================================================================ + +-- Rename applications -> job_applications +ALTER TABLE applications RENAME TO job_applications; + +-- Rename requirements -> leads +ALTER TABLE requirements RENAME TO leads; + +-- Rename coupon_uses -> coupon_redemptions +ALTER TABLE coupon_uses RENAME TO coupon_redemptions; + +-- ============================================================================ +-- PHASE 7: Create New Domain Tables +-- ============================================================================ + +-- 7.1 verification_requests +CREATE TABLE IF NOT EXISTS verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + verification_type VARCHAR(50) NOT NULL DEFAULT 'IDENTITY', + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.2 verification_documents +CREATE TABLE IF NOT EXISTS verification_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_url TEXT NOT NULL, + file_name TEXT, + mime_type TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.3 verification_logs +CREATE TABLE IF NOT EXISTS verification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.4 approval_requests +CREATE TABLE IF NOT EXISTS approval_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + approval_type VARCHAR(50), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_by_user_id UUID REFERENCES users(id), + reviewed_by_user_id UUID REFERENCES users(id), + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.5 approval_logs +CREATE TABLE IF NOT EXISTS approval_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + approval_request_id UUID NOT NULL REFERENCES approval_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.6 audit_logs +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_user_id UUID REFERENCES users(id), + actor_employee_id UUID, + actor_type VARCHAR(50), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(100), + entity_id UUID, + entity_label TEXT, + module_key VARCHAR(100), + source_type VARCHAR(50), + source_id UUID, + request_id UUID, + correlation_id UUID, + ip_address TEXT, + user_agent TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'SUCCESS', + summary TEXT, + metadata_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.7 audit_log_changes +CREATE TABLE IF NOT EXISTS audit_log_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + audit_log_id UUID NOT NULL REFERENCES audit_logs(id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + old_value_text TEXT, + new_value_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_module ON audit_logs(module_key); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at); + +-- 7.8 orders +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + order_type VARCHAR(50) NOT NULL DEFAULT 'PACKAGE', + subtotal_inr INTEGER NOT NULL DEFAULT 0, + discount_inr INTEGER NOT NULL DEFAULT 0, + tax_inr INTEGER NOT NULL DEFAULT 0, + total_inr INTEGER NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.9 order_items +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + item_type VARCHAR(50) NOT NULL, + item_id UUID, + item_name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price_inr INTEGER NOT NULL, + total_price_inr INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.10 payment_gateway_configs +CREATE TABLE IF NOT EXISTS payment_gateway_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_key VARCHAR(50) NOT NULL, + display_name VARCHAR(255), + config_json JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.11 payment_transactions +CREATE TABLE IF NOT EXISTS payment_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments(id), + transaction_type VARCHAR(50) NOT NULL, + provider_reference TEXT, + request_payload_json JSONB, + response_payload_json JSONB, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.12 tax_rules +CREATE TABLE IF NOT EXISTS tax_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + tax_type VARCHAR(50) NOT NULL, + tax_rate DECIMAL(5,2) NOT NULL, + applies_to VARCHAR(50), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.13 kb_sections +CREATE TABLE IF NOT EXISTS kb_sections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT, + display_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.14 kb_article_feedback +CREATE TABLE IF NOT EXISTS kb_article_feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES kb_articles(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), + is_helpful BOOLEAN, + feedback_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.15 notification_templates +CREATE TABLE IF NOT EXISTS notification_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_key VARCHAR(100) NOT NULL UNIQUE, + channel VARCHAR(50) NOT NULL DEFAULT 'EMAIL', + title_template TEXT, + body_template TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.16 smtp_configs +CREATE TABLE IF NOT EXISTS smtp_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider_name VARCHAR(100), + host VARCHAR(255), + port INTEGER, + username TEXT, + encryption_mode VARCHAR(20), + from_name VARCHAR(255), + from_email VARCHAR(255), + is_default BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.17 dashboard_widgets +CREATE TABLE IF NOT EXISTS dashboard_widgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dashboard_config_id UUID NOT NULL REFERENCES dashboard_configs(id) ON DELETE CASCADE, + widget_key VARCHAR(100) NOT NULL, + widget_title VARCHAR(255), + config_json JSONB, + display_order INTEGER DEFAULT 0, + width_units INTEGER DEFAULT 1, + height_units INTEGER DEFAULT 1, + is_visible BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- PHASE 8: Update Existing Tables +-- ============================================================================ + +-- Update users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS account_type TEXT DEFAULT 'INDIVIDUAL'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE users SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update roles table +ALTER TABLE roles ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS can_approve_requests BOOLEAN DEFAULT false; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS can_manage_system_settings BOOLEAN DEFAULT false; + +-- Update departments table +ALTER TABLE departments ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_head VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_email VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'INTERNAL'; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS transfers_enabled BOOLEAN DEFAULT false; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE departments SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update designations table +ALTER TABLE designations ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS level VARCHAR(100); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_manage_team BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_approve BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE designations SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update employees table +ALTER TABLE employees ADD COLUMN IF NOT EXISTS joining_date DATE; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS employment_status VARCHAR(50) DEFAULT 'ACTIVE'; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS manager_employee_id UUID REFERENCES employees(id); +ALTER TABLE employees ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE employees SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update lead_requests table +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE lead_requests lr SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE lr.professional_id IS NOT NULL + AND urp.user_id = (SELECT user_id FROM professionals p WHERE p.id = lr.professional_id); +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS remarks TEXT; +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Update job_applications table +ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS applicant_user_id UUID; +UPDATE job_applications ja SET applicant_user_id = ( + SELECT user_id FROM job_seeker_profiles jsp WHERE jsp.id = ja.job_seeker_id +); +ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS cover_note TEXT; +ALTER TABLE job_applications DROP COLUMN IF EXISTS job_seeker_id; +ALTER TABLE job_applications DROP COLUMN IF EXISTS cover_letter; +ALTER TABLE job_applications DROP COLUMN IF EXISTS resume_url; +ALTER TABLE job_applications DROP COLUMN IF EXISTS contact_viewed; + +-- Update leads table (formerly requirements) +ALTER TABLE leads ADD COLUMN IF NOT EXISTS created_by_user_id UUID; +UPDATE leads l SET created_by_user_id = ( + SELECT user_id FROM customer_profiles cp WHERE cp.id = l.customer_id +); +ALTER TABLE leads ADD COLUMN IF NOT EXISTS required_date DATE; +ALTER TABLE leads ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE leads DROP COLUMN IF EXISTS customer_id; + +-- Update jobs table +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS posted_by_user_id UUID; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode_of_work VARCHAR(50); +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS budget_inr INTEGER; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS salary_range_json JSONB; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE jobs SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update tracecoin_wallets +ALTER TABLE tracecoin_wallets RENAME COLUMN balance TO current_balance; +ALTER TABLE tracecoin_wallets ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Update tracecoin_ledger +ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS balance_after INTEGER; +ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS remarks TEXT; +ALTER TABLE tracecoin_ledger RENAME COLUMN type TO transaction_type; +ALTER TABLE tracecoin_ledger RENAME COLUMN reason TO reference_type; + +-- Update coupons +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS max_discount_inr INTEGER; +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS min_order_value_inr INTEGER DEFAULT 0; +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ DEFAULT NOW(); +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS valid_to TIMESTAMPTZ; + +-- Update coupon_redemptions +ALTER TABLE coupon_redemptions ADD COLUMN IF NOT EXISTS order_id UUID REFERENCES orders(id); +ALTER TABLE coupon_redemptions ADD COLUMN IF NOT EXISTS discount_amount_inr INTEGER; +ALTER TABLE coupon_redemptions RENAME COLUMN used_at TO redeemed_at; + +-- Update invoices +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS order_id UUID REFERENCES orders(id); +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS discount_inr INTEGER DEFAULT 0; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS due_at TIMESTAMPTZ; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ; + +-- Update payments +ALTER TABLE payments ADD COLUMN IF NOT EXISTS payment_gateway_config_id UUID REFERENCES payment_gateway_configs(id); +ALTER TABLE payments ADD COLUMN IF NOT EXISTS payment_method VARCHAR(50); +ALTER TABLE payments ADD COLUMN IF NOT EXISTS currency_code VARCHAR(10) DEFAULT 'INR'; +ALTER TABLE payments ADD COLUMN IF NOT EXISTS initiated_at TIMESTAMPTZ DEFAULT NOW(); +ALTER TABLE payments ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ; + +-- Update kb_articles +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS section_id UUID REFERENCES kb_sections(id); +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS article_type VARCHAR(50) DEFAULT 'HOW_TO'; +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS audience_type VARCHAR(50) DEFAULT 'ALL'; +ALTER TABLE kb_articles RENAME COLUMN body TO content_markdown; +ALTER TABLE kb_articles RENAME COLUMN created_by TO author_user_id; + +-- Update support_tickets +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS created_by_user_id UUID; +UPDATE support_tickets SET created_by_user_id = user_id; +ALTER TABLE support_tickets RENAME COLUMN assigned_to TO assigned_to_user_id; +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS related_entity_type VARCHAR(50); +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS related_entity_id UUID; +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; + +-- Update support_ticket_messages +ALTER TABLE support_ticket_messages ADD COLUMN IF NOT EXISTS sender_user_id UUID; +UPDATE support_ticket_messages SET sender_user_id = sender_id; +ALTER TABLE support_ticket_messages RENAME COLUMN body TO message_body; +ALTER TABLE support_ticket_messages ADD COLUMN IF NOT EXISTS attachment_url TEXT; + +-- Update notifications +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS channel VARCHAR(50) DEFAULT 'IN_APP'; +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS related_entity_type VARCHAR(50); +ALTER TABLE notifications RENAME COLUMN reference_id TO related_entity_id; + +-- Update reviews +ALTER TABLE reviews ADD COLUMN IF NOT EXISTS entity_type VARCHAR(50) DEFAULT 'professional'; +ALTER TABLE reviews RENAME COLUMN customer_id TO reviewer_user_id; +ALTER TABLE reviews ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'PUBLISHED'; + +-- ============================================================================ +-- PHASE 9: Drop Deprecated Tables +-- ============================================================================ + +DROP TABLE IF EXISTS professionals CASCADE; +DROP TABLE IF EXISTS onboarding_submissions CASCADE; +DROP TABLE IF EXISTS onboarding_configs CASCADE; +DROP TABLE IF EXISTS onboarding_states CASCADE; +DROP TABLE IF EXISTS submission_documents CASCADE; + +-- ============================================================================ +-- PHASE 10: Drop Deprecated Columns from Extension Tables +-- ============================================================================ + +-- Drop old user_id columns from extension tables (AFTER backfilling user_role_profile_id) +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS user_id; + +-- Drop old columns from portfolio_items +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS professional_id; +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS user_id; +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS profession_key; + +-- Drop old columns from services +ALTER TABLE services DROP COLUMN IF EXISTS professional_id; +ALTER TABLE services DROP COLUMN IF EXISTS user_id; +ALTER TABLE services DROP COLUMN IF EXISTS profession_key; + +-- Drop old columns from lead_requests +ALTER TABLE lead_requests DROP COLUMN IF EXISTS professional_id; +ALTER TABLE lead_requests DROP COLUMN IF EXISTS requirement_id; + +-- Drop old custom_data columns +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS custom_data; + +-- Drop old profile columns that are now in user_role_profiles +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS approved_at; + +COMMIT; + +-- ============================================================================ +-- Migration Complete +-- ============================================================================ diff --git a/crates/db/migrations_new/20260415000001_create_user_sessions.down.sql b/crates/db/migrations_new/20260415000001_create_user_sessions.down.sql new file mode 100644 index 0000000..141c136 --- /dev/null +++ b/crates/db/migrations_new/20260415000001_create_user_sessions.down.sql @@ -0,0 +1,2 @@ +-- Rollback: Drop user_sessions table +DROP TABLE IF EXISTS user_sessions; diff --git a/crates/db/migrations_new/20260415000001_create_user_sessions.up.sql b/crates/db/migrations_new/20260415000001_create_user_sessions.up.sql new file mode 100644 index 0000000..150980b --- /dev/null +++ b/crates/db/migrations_new/20260415000001_create_user_sessions.up.sql @@ -0,0 +1,16 @@ +-- Phase 1.1: Create user_sessions table +-- Migration: 20260415000001 + +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); diff --git a/crates/db/migrations_new/20260415010001_create_user_role_profiles.down.sql b/crates/db/migrations_new/20260415010001_create_user_role_profiles.down.sql new file mode 100644 index 0000000..532bb47 --- /dev/null +++ b/crates/db/migrations_new/20260415010001_create_user_role_profiles.down.sql @@ -0,0 +1,3 @@ +-- Rollback: Drop user_role_profiles table +-- WARNING: This will fail if data exists and FK constraints are in place +DROP TABLE IF EXISTS user_role_profiles CASCADE; diff --git a/crates/db/migrations_new/20260415010001_create_user_role_profiles.up.sql b/crates/db/migrations_new/20260415010001_create_user_role_profiles.up.sql new file mode 100644 index 0000000..58ef62d --- /dev/null +++ b/crates/db/migrations_new/20260415010001_create_user_role_profiles.up.sql @@ -0,0 +1,31 @@ +-- Phase 2.1: Create user_role_profiles root table (CRITICAL) +-- Migration: 20260415010001 +-- This is the ROOT table for all user role profiles + +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); + +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_status ON user_role_profiles(status); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_verification ON user_role_profiles(verification_status); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_approval ON user_role_profiles(approval_status); diff --git a/crates/db/migrations_new/20260415010002_backfill_user_role_profiles.down.sql b/crates/db/migrations_new/20260415010002_backfill_user_role_profiles.down.sql new file mode 100644 index 0000000..bb0ee89 --- /dev/null +++ b/crates/db/migrations_new/20260415010002_backfill_user_role_profiles.down.sql @@ -0,0 +1,2 @@ +-- Rollback: Clear backfilled data (run before dropping user_role_profiles) +DELETE FROM user_role_profiles WHERE created_at > '2024-04-15'; diff --git a/crates/db/migrations_new/20260415010003_add_user_role_profile_id.down.sql b/crates/db/migrations_new/20260415010003_add_user_role_profile_id.down.sql new file mode 100644 index 0000000..c5d1c76 --- /dev/null +++ b/crates/db/migrations_new/20260415010003_add_user_role_profile_id.down.sql @@ -0,0 +1,23 @@ +-- Rollback: Remove user_role_profile_id columns +-- WARNING: This will fail if FK constraints exist +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS user_role_profile_id; + +DROP INDEX IF EXISTS idx_photographer_profiles_user_role; +DROP INDEX IF EXISTS idx_tutor_profiles_user_role; +DROP INDEX IF EXISTS idx_makeup_artist_profiles_user_role; +DROP INDEX IF EXISTS idx_developer_profiles_user_role; +DROP INDEX IF EXISTS idx_video_editor_profiles_user_role; +DROP INDEX IF EXISTS idx_graphic_designer_profiles_user_role; +DROP INDEX IF EXISTS idx_social_media_manager_profiles_user_role; +DROP INDEX IF EXISTS idx_fitness_trainer_profiles_user_role; +DROP INDEX IF EXISTS idx_catering_service_profiles_user_role; +DROP INDEX IF EXISTS idx_ugc_content_creator_profiles_user_role; diff --git a/crates/db/migrations_new/20260415010003_add_user_role_profile_id.up.sql.skip b/crates/db/migrations_new/20260415010003_add_user_role_profile_id.up.sql.skip new file mode 100644 index 0000000..fec1959 --- /dev/null +++ b/crates/db/migrations_new/20260415010003_add_user_role_profile_id.up.sql.skip @@ -0,0 +1,85 @@ +-- Phase 2.3: Add user_role_profile_id to extension tables +-- Migration: 20260415010003 +-- This links existing extension tables to the new user_role_profiles root + +-- photographer_profiles +ALTER TABLE photographer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE photographer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'photographer'; +ALTER TABLE photographer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- tutor_profiles +ALTER TABLE tutor_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE tutor_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'tutor'; +ALTER TABLE tutor_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- makeup_artist_profiles +ALTER TABLE makeup_artist_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE makeup_artist_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'makeup_artist'; +ALTER TABLE makeup_artist_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- developer_profiles +ALTER TABLE developer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE developer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'developer'; +ALTER TABLE developer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- video_editor_profiles +ALTER TABLE video_editor_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE video_editor_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'video_editor'; +ALTER TABLE video_editor_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- graphic_designer_profiles +ALTER TABLE graphic_designer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE graphic_designer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'graphic_designer'; +ALTER TABLE graphic_designer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- social_media_manager_profiles +ALTER TABLE social_media_manager_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE social_media_manager_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'social_media_manager'; +ALTER TABLE social_media_manager_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- fitness_trainer_profiles +ALTER TABLE fitness_trainer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE fitness_trainer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'fitness_trainer'; +ALTER TABLE fitness_trainer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- catering_service_profiles +ALTER TABLE catering_service_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE catering_service_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'catering_service'; +ALTER TABLE catering_service_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- ugc_content_creator_profiles +ALTER TABLE ugc_content_creator_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE ugc_content_creator_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'ugc_content_creator'; +ALTER TABLE ugc_content_creator_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_photographer_profiles_user_role ON photographer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_tutor_profiles_user_role ON tutor_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_makeup_artist_profiles_user_role ON makeup_artist_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_developer_profiles_user_role ON developer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_video_editor_profiles_user_role ON video_editor_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_graphic_designer_profiles_user_role ON graphic_designer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_social_media_manager_profiles_user_role ON social_media_manager_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_fitness_trainer_profiles_user_role ON fitness_trainer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_catering_service_profiles_user_role ON catering_service_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_ugc_content_creator_profiles_user_role ON ugc_content_creator_profiles(user_role_profile_id); diff --git a/crates/db/migrations_new/20260415010004_remove_external_links.down.sql b/crates/db/migrations_new/20260415010004_remove_external_links.down.sql new file mode 100644 index 0000000..c6d9593 --- /dev/null +++ b/crates/db/migrations_new/20260415010004_remove_external_links.down.sql @@ -0,0 +1,3 @@ +-- Rollback: Cannot easily restore removed columns +-- This migration is NOT easily reversible +-- Only run after full backup and testing diff --git a/crates/db/migrations_new/20260415010004_remove_external_links.up.sql.skip b/crates/db/migrations_new/20260415010004_remove_external_links.up.sql.skip new file mode 100644 index 0000000..334c090 --- /dev/null +++ b/crates/db/migrations_new/20260415010004_remove_external_links.up.sql.skip @@ -0,0 +1,31 @@ +-- Phase 2.4: Remove forbidden external portfolio links +-- Migration: 20260415010004 +-- Per source of truth: NO external portfolio links allowed + +-- Remove github_url, portfolio_url from developer_profiles +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS github_url; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove reel_url from video_editor_profiles +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS reel_url; + +-- Remove portfolio_url from graphic_designer_profiles +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove portfolio_url from photographer_profiles +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove custom_data from all extension tables (preserve as JSONB if needed) +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS custom_data; + +-- Rename inconsistent columns +ALTER TABLE tutor_profiles RENAME COLUMN subjects_taught TO subjects;