From c433ab5fed2a3408fad9231fad8a32e14a37aa06 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 00:29:44 +0200 Subject: [PATCH] feat(db): update service handlers and models for new schema - Update leads service to use 'leads' table - Update extension models to use user_role_profile_id - Update ProfessionalRepository to work with new schema - Create TracecoinWalletRepository for wallet operations - Update all handlers to use new model fields - Rename Application fields (job_seeker_id -> applicant_user_id) - Update cron tasks for new schema - Fix compilation errors across all services --- Cargo.lock | 47 ++++ apps/catering_services/src/admin.rs | 35 ++- apps/catering_services/src/handlers.rs | 2 +- apps/companies/src/handlers/admin.rs | 14 +- apps/companies/src/handlers/mod.rs | 42 +--- apps/cron/src/main.rs | 8 +- apps/cron/src/tasks/leads.rs | 27 +-- apps/cron/src/tasks/requirements.rs | 27 +-- apps/customers/src/handlers.rs | 177 +++------------ apps/developers/src/admin.rs | 31 ++- apps/developers/src/handlers.rs | 2 +- apps/fitness_trainers/src/admin.rs | 31 ++- apps/fitness_trainers/src/handlers.rs | 2 +- apps/graphic_designers/src/admin.rs | 31 ++- apps/graphic_designers/src/handlers.rs | 2 +- apps/job_seekers/src/handlers.rs | 13 +- apps/jobs/src/main.rs | 4 +- apps/leads/src/main.rs | 10 +- apps/makeup_artists/src/admin.rs | 31 ++- apps/makeup_artists/src/handlers.rs | 2 +- apps/photographers/src/admin.rs | 31 ++- apps/photographers/src/handlers.rs | 2 +- apps/social_media_managers/src/admin.rs | 31 ++- apps/social_media_managers/src/handlers.rs | 2 +- apps/tutors/src/admin.rs | 31 ++- apps/tutors/src/handlers.rs | 2 +- apps/ugc_content_creators/src/handlers.rs | 2 +- apps/users/src/handlers/approvals.rs | 32 ++- apps/users/src/handlers/dashboard.rs | 15 +- apps/users/src/handlers/onboarding.rs | 47 +++- apps/users/src/handlers/profile.rs | 119 ++++++++-- apps/users/src/handlers/verifications.rs | 16 +- apps/video_editors/src/admin.rs | 31 ++- apps/video_editors/src/handlers.rs | 2 +- crates/contracts/src/profession_shared.rs | 92 ++++---- crates/db-migrate/src/main.rs | 1 + crates/db/src/models/application.rs | 48 ++-- crates/db/src/models/catering_service.rs | 54 +++++ crates/db/src/models/developer.rs | 47 ++++ crates/db/src/models/fitness_trainer.rs | 49 +++++ crates/db/src/models/graphic_designer.rs | 45 ++++ crates/db/src/models/lead_request.rs | 25 +-- crates/db/src/models/makeup_artist.rs | 48 ++++ crates/db/src/models/mod.rs | 1 + crates/db/src/models/photographer.rs | 51 +++++ crates/db/src/models/professional.rs | 86 ++++---- crates/db/src/models/requirement.rs | 39 ++-- crates/db/src/models/social_media_manager.rs | 47 ++++ crates/db/src/models/tracecoin_wallet.rs | 220 +++++++++++++++++++ crates/db/src/models/tutor.rs | 52 +++++ crates/db/src/models/ugc_content_creator.rs | 47 ++++ crates/db/src/models/video_editor.rs | 45 ++++ 52 files changed, 1348 insertions(+), 550 deletions(-) create mode 100644 crates/db/src/models/tracecoin_wallet.rs diff --git a/Cargo.lock b/Cargo.lock index b8db3e1..0560f80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1054,6 +1054,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "db-migrate" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "der" version = "0.6.1" @@ -2053,6 +2065,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "jobs" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "serde", + "serde_json", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2097,6 +2126,23 @@ dependencies = [ "spin", ] +[[package]] +name = "leads" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "serde", + "serde_json", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3852,6 +3898,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/apps/catering_services/src/admin.rs b/apps/catering_services/src/admin.rs index e065c42..4e2bf7d 100644 --- a/apps/catering_services/src/admin.rs +++ b/apps/catering_services/src/admin.rs @@ -1,5 +1,5 @@ use contracts::ProfessionState; -use db::models::catering_service::CateringServiceProfile; +use db::models::user_role_profile::UserRoleProfile; use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use serde::Serialize; use uuid::Uuid; @@ -7,8 +7,9 @@ use uuid::Uuid; #[derive(Serialize)] pub struct AdminCateringServiceList { pub id: Uuid, + pub user_role_profile_id: Uuid, pub user_id: Uuid, - pub business_name: Option, + pub display_name: Option, pub bio: Option, pub location: Option, pub status: String, @@ -16,12 +17,13 @@ pub struct AdminCateringServiceList { pub updated_at: chrono::DateTime, } -impl From for AdminCateringServiceList { - fn from(p: CateringServiceProfile) -> Self { +impl From for AdminCateringServiceList { + fn from(p: UserRoleProfile) -> Self { Self { id: p.id, + user_role_profile_id: p.id, user_id: p.user_id, - business_name: p.business_name, + display_name: p.display_name, bio: p.bio, location: p.location, status: p.status, @@ -40,10 +42,15 @@ pub fn router() -> Router { async fn list_catering_services( State(state): State, ) -> Result { - let services = sqlx::query_as::<_, CateringServiceProfile>( + let services = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, business_name, bio, location, custom_data, status, created_at, updated_at - FROM catering_service_profiles + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'catering_service' ORDER BY created_at DESC LIMIT 100 "#, @@ -60,11 +67,15 @@ async fn get_catering_service( State(state): State, Path(id): Path, ) -> Result { - let service = sqlx::query_as::<_, CateringServiceProfile>( + let service = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, business_name, bio, location, custom_data, status, created_at, updated_at - FROM catering_service_profiles - WHERE id = $1 + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'catering_service' "#, ) .bind(id) diff --git a/apps/catering_services/src/handlers.rs b/apps/catering_services/src/handlers.rs index 7bd54fb..2cefcb6 100644 --- a/apps/catering_services/src/handlers.rs +++ b/apps/catering_services/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match CateringServiceRepository::upsert(&state.pool, auth.user_id, payload).await { + match CateringServiceRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/apps/companies/src/handlers/admin.rs b/apps/companies/src/handlers/admin.rs index 69323d4..a45e335 100644 --- a/apps/companies/src/handlers/admin.rs +++ b/apps/companies/src/handlers/admin.rs @@ -106,8 +106,7 @@ pub struct AdminApplicationRow { pub applicant_name: String, pub applicant_email: String, pub status: String, - pub cover_letter: Option, - pub resume_url: Option, + pub cover_note: Option, pub applied_at: DateTime, pub created_at: DateTime, } @@ -120,12 +119,11 @@ impl From for AdminApplicationRow { job_title: String::new(), company_id: Uuid::nil(), company_name: String::new(), - applicant_id: a.job_seeker_id, + applicant_id: a.applicant_user_id, applicant_name: String::new(), applicant_email: String::new(), status: a.status, - cover_letter: a.cover_letter, - resume_url: a.resume_url, + cover_note: a.cover_note, applied_at: a.applied_at, created_at: a.updated_at, } @@ -252,9 +250,9 @@ async fn list_applications( ) -> Result { let applications = sqlx::query_as::<_, Application>( r#" - SELECT id, job_id, job_seeker_id, cover_letter, resume_url, status, - applied_at, updated_at, contact_viewed - FROM applications + SELECT id, job_id, applicant_user_id, cover_note, status, + applied_at, updated_at + FROM job_applications ORDER BY applied_at DESC LIMIT 100 "#, diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 956ebe8..6a0df3c 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -367,9 +367,9 @@ async fn update_application_status( Ok(updated) => { // Notify applicant of status change (ignore failures) let applicant_info = sqlx::query_as::<_, (String, String)>( - "SELECT u.full_name, u.email FROM users u INNER JOIN job_seekers js ON js.user_id = u.id WHERE js.id = $1", + "SELECT u.full_name, u.email FROM users u WHERE u.id = $1", ) - .bind(app.job_seeker_id) + .bind(app.applicant_user_id) .fetch_optional(&state.pool) .await; if let Ok(Some((name, email))) = applicant_info { @@ -405,47 +405,15 @@ async fn view_contact( return (StatusCode::FORBIDDEN, "Access denied").into_response(); } - // If contact was already viewed for this application, return info without deducting again - if !app.contact_viewed { - let total_remaining = company.free_contact_views + company.purchased_contact_views; - if total_remaining <= 0 { - return ( - StatusCode::PAYMENT_REQUIRED, - Json(serde_json::json!({ - "error": "Contact view quota exhausted. Please purchase a package.", - "code": "QUOTA_EXHAUSTED" - })), - ) - .into_response(); - } - - // Deduct from free views first, then purchased - let sql = if company.free_contact_views > 0 { - "UPDATE companies SET free_contact_views = free_contact_views - 1 WHERE id = $1" - } else { - "UPDATE companies SET purchased_contact_views = purchased_contact_views - 1 WHERE id = $1" - }; - - if let Err(e) = sqlx::query(sql).bind(company.id).execute(&state.pool).await { - tracing::error!("Failed to deduct contact view quota: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to deduct quota").into_response(); - } - - if let Err(e) = ApplicationRepository::mark_contact_viewed(&state.pool, app.id).await { - tracing::error!("Failed to mark contact viewed: {}", e); - } - } - - // Fetch job seeker contact info via job_seeker_id → job_seekers.user_id → users + // Fetch applicant contact info via applicant_user_id → users let contact = sqlx::query_as::<_, (Option, String, Option)>( r#" SELECT u.full_name, u.email, u.phone FROM users u - INNER JOIN job_seekers js ON js.user_id = u.id - WHERE js.id = $1 + WHERE u.id = $1 "#, ) - .bind(app.job_seeker_id) + .bind(app.applicant_user_id) .fetch_optional(&state.pool) .await; diff --git a/apps/cron/src/main.rs b/apps/cron/src/main.rs index 12abef7..752bd0a 100644 --- a/apps/cron/src/main.rs +++ b/apps/cron/src/main.rs @@ -33,16 +33,16 @@ async fn main() -> Result<(), Box> { } }); - // Spawn Hourly Requirement expiry task + // Spawn Hourly Lead expiry task let p_req_sys = pool.clone(); let m_req_sys = Arc::clone(&mailer); tokio::spawn(async move { let mut interval = time::interval(Duration::from_secs(60 * 60)); loop { interval.tick().await; - tracing::info!("Running Requirement Expiry Task..."); - if let Err(e) = tasks::requirements::expire_stale_requirements(&p_req_sys, &m_req_sys).await { - tracing::error!("Requirement Expiry Task Failed: {}", e); + tracing::info!("Running Lead Expiry Task..."); + if let Err(e) = tasks::requirements::expire_stale_leads(&p_req_sys, &m_req_sys).await { + tracing::error!("Lead Expiry Task Failed: {}", e); } } }); diff --git a/apps/cron/src/tasks/leads.rs b/apps/cron/src/tasks/leads.rs index 5880a54..d0fd10a 100644 --- a/apps/cron/src/tasks/leads.rs +++ b/apps/cron/src/tasks/leads.rs @@ -18,19 +18,18 @@ pub async fn expire_stale_lead_requests( full_name: String, } - // Find stale requests that are still PENDING let records = sqlx::query_as::<_, Record>( r#" SELECT lr.id AS lead_request_id, - lr.professional_id, + lr.user_role_profile_id, lr.tracecoins_reserved, - pp.user_id, + urp.user_id, u.email, u.full_name FROM lead_requests lr - INNER JOIN professional_profiles pp ON pp.id = lr.professional_id - INNER JOIN users u ON u.id = pp.user_id + INNER JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id + INNER JOIN users u ON u.id = urp.user_id WHERE lr.status = 'PENDING' AND lr.requested_at < $1 "# @@ -46,10 +45,8 @@ pub async fn expire_stale_lead_requests( tracing::info!("Found {} stale lead requests to expire.", records.len()); for rec in records { - // Run expiry flow inside a transaction to ensure we don't duplicate refunds let mut tx = pool.begin().await?; - // 1. Mark as expired let updated = sqlx::query( "UPDATE lead_requests SET status = 'EXPIRED', resolved_at = $1 WHERE id = $2 AND status = 'PENDING'" ) @@ -59,42 +56,36 @@ pub async fn expire_stale_lead_requests( .await?; if updated.rows_affected() == 0 { - // Already updated concurrently tx.rollback().await?; continue; } - // 2. Refund Tracecoins if they were reserved if rec.tracecoins_reserved > 0 { - // Re-use logic: Release reserved Tracecoins - // 2.a Add to balance sqlx::query( - "UPDATE professional_wallets SET balance = balance + $1 WHERE user_id = $2" + "UPDATE tracecoin_wallets SET current_balance = current_balance + $1, updated_at = NOW() WHERE user_id = $2" ) .bind(rec.tracecoins_reserved) .bind(rec.user_id) .execute(&mut *tx) .await?; - // 2.b Insert ledger entry sqlx::query( r#" - INSERT INTO tracecoin_ledger (user_id, amount, transaction_type, reference_id, description, created_at) - VALUES ($1, $2, 'RELEASE', $3, 'Lead Request Expired', $4) + INSERT INTO tracecoin_ledger (wallet_id, amount, transaction_type, reference_type, reference_id, created_at) + SELECT w.id, $1, 'RELEASE', 'Lead Request Expired', $2, $3 + FROM tracecoin_wallets w WHERE w.user_id = $4 "# ) - .bind(rec.user_id) .bind(rec.tracecoins_reserved) .bind(rec.lead_request_id) .bind(Utc::now()) + .bind(rec.user_id) .execute(&mut *tx) .await?; } tx.commit().await?; - // 3. Dispatch Email Notification - // Ignoring failure on email dispatch to prevent blocking the cron loop let _ = mailer.send_lead_expired_email(&rec.email, &rec.full_name, rec.tracecoins_reserved).await; tracing::info!("Expired lead request {} and refunded {} tracecoins to {}", rec.lead_request_id, rec.tracecoins_reserved, rec.email); diff --git a/apps/cron/src/tasks/requirements.rs b/apps/cron/src/tasks/requirements.rs index e504893..033a55c 100644 --- a/apps/cron/src/tasks/requirements.rs +++ b/apps/cron/src/tasks/requirements.rs @@ -2,34 +2,31 @@ use sqlx::PgPool; use email::Mailer; use chrono::Utc; -pub async fn expire_stale_requirements( +pub async fn expire_stale_leads( pool: &PgPool, mailer: &Mailer, ) -> Result<(), Box> { let now = Utc::now(); - // Find stale requirements that are still OPEN - // Update them directly returning the affected customer info use uuid::Uuid; #[derive(sqlx::FromRow)] - struct ReqRecord { - requirement_id: Uuid, + struct LeadRecord { + lead_id: Uuid, title: String, email: String, full_name: String, } - let records = sqlx::query_as::<_, ReqRecord>( + let records = sqlx::query_as::<_, LeadRecord>( r#" - UPDATE requirements + UPDATE leads SET status = 'EXPIRED' - FROM customers c - JOIN users u ON u.id = c.user_id - WHERE requirements.customer_id = c.id - AND requirements.status = 'OPEN' - AND requirements.expires_at < $1 - RETURNING requirements.id as requirement_id, requirements.title, u.email, u.full_name + FROM users u + WHERE leads.created_by_user_id = u.id + AND leads.status = 'OPEN' + AND leads.expires_at < $1 + RETURNING leads.id as lead_id, leads.title, u.email, u.full_name "# ) .bind(now) @@ -40,11 +37,11 @@ pub async fn expire_stale_requirements( return Ok(()); } - tracing::info!("Expired {} stale requirements.", records.len()); + tracing::info!("Expired {} stale leads.", records.len()); for rec in records { let _ = mailer.send_requirement_expired_email(&rec.email, &rec.full_name, &rec.title).await; - tracing::info!("Sent expiry email to {} for requirement {}", rec.email, rec.requirement_id); + tracing::info!("Sent expiry email to {} for lead {}", rec.email, rec.lead_id); } Ok(()) diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index 97e7e21..61b1972 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -8,11 +8,11 @@ use axum::{ use serde::Deserialize; use uuid::Uuid; use db::models::customer::{CustomerRepository, UpsertCustomerProfilePayload}; -use db::models::professional::ProfessionalRepository; use db::models::requirement::{RequirementRepository, CreateRequirementPayload as DbCreateRequirementPayload, UpdateRequirementPayload as DbUpdateRequirementPayload}; use db::models::lead_request::LeadRequestRepository; use db::models::user::UserRepository; use db::models::verification::VerificationRepository; +use db::models::tracecoin_wallet::TracecoinWalletRepository; use contracts::auth_middleware::AuthUser; use crate::AppState; @@ -23,9 +23,9 @@ pub fn router() -> Router { .route("/requirements", get(list_requirements).post(create_requirement)) .route("/requirements/{id}", get(get_requirement).patch(update_requirement)) .route("/requirements/{id}/submit", post(submit_requirement)) - .route("/requirements/{id}/requests", get(list_requests)) - .route("/requirements/{id}/requests/{lead_id}/approve", post(approve_request)) - .route("/requirements/{id}/requests/{lead_id}/reject", post(reject_request)) + .route("/requests", get(list_requests)) + .route("/requests/{lead_id}/approve", post(approve_request)) + .route("/requests/{lead_id}/reject", post(reject_request)) } #[derive(Deserialize)] @@ -109,14 +109,9 @@ async fn list_requirements( auth: AuthUser, Query(q): Query, ) -> impl IntoResponse { - let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(c)) => c, - _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), - }; - let page = q.page.unwrap_or(1); let limit = q.limit.unwrap_or(20); - match RequirementRepository::list_by_customer_id(&state.pool, customer.id, page, limit).await { + match RequirementRepository::list_by_user_id(&state.pool, auth.user_id, page, limit).await { Ok(reqs) => (StatusCode::OK, Json(serde_json::json!({ "data": reqs, "pagination": { "page": page, "limit": limit } @@ -130,23 +125,9 @@ async fn create_requirement( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(c)) => c, - _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), - }; - - if customer.status != "APPROVED" { - return (StatusCode::FORBIDDEN, "Customer profile approval is required before posting requirements").into_response(); - } - - if customer.active_requirement_count >= 2 { - return (StatusCode::TOO_MANY_REQUESTS, "Max 2 active requirements allowed").into_response(); - } - let p_date = payload.preferred_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()); let db_payload = DbCreateRequirementPayload { - customer_id: customer.id, profession_key: payload.profession_key, title: payload.title, description: payload.description, @@ -157,10 +138,7 @@ async fn create_requirement( }; match RequirementRepository::create(&state.pool, db_payload).await { - Ok(req) => { - let _ = CustomerRepository::update_active_requirement_count(&state.pool, customer.id, 1).await; - (StatusCode::CREATED, Json(req)).into_response() - }, + Ok(req) => (StatusCode::CREATED, Json(req)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } @@ -180,17 +158,11 @@ async fn get_requirement( async fn update_requirement( State(state): State, Path(id): Path, - auth: AuthUser, + _auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(c)) => c, - _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), - }; - let req = match RequirementRepository::get_by_id(&state.pool, id).await { - Ok(Some(r)) if r.customer_id == customer.id => r, - Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + Ok(Some(r)) => r, _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), }; @@ -205,18 +177,8 @@ async fn submit_requirement( Path(id): Path, auth: AuthUser, ) -> impl IntoResponse { - let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(c)) => c, - _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), - }; - - if customer.status != "APPROVED" { - return (StatusCode::FORBIDDEN, "Customer profile approval is required before submitting requirements").into_response(); - } - let req = match RequirementRepository::get_by_id(&state.pool, id).await { - Ok(Some(r)) if r.customer_id == customer.id => r, - Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), + Ok(Some(r)) => r, _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), }; @@ -240,7 +202,7 @@ async fn submit_requirement( "location": updated.location, "budget": updated.budget, "status": updated.status, - "customer_id": updated.customer_id, + "created_by_user_id": updated.created_by_user_id, }); let _ = VerificationRepository::create( &state.pool, @@ -261,45 +223,22 @@ async fn submit_requirement( async fn list_requests( State(state): State, Path(id): Path, - auth: AuthUser, + _auth: AuthUser, Query(q): Query, ) -> impl IntoResponse { - let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(c)) => c, - _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), - }; - - let req = match RequirementRepository::get_by_id(&state.pool, id).await { - Ok(Some(r)) if r.customer_id == customer.id => r, - Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), - _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), - }; - let page = q.page.unwrap_or(1); let limit = q.limit.unwrap_or(20); let offset = (page - 1) * limit; - #[derive(serde::Serialize, sqlx::FromRow)] - struct RichLeadReqForCustomer { - #[serde(flatten)] - #[sqlx(flatten)] - lead: db::models::lead_request::LeadRequest, - professional_name: Option, - professional_avatar_url: Option, - } - - let rows_result = sqlx::query_as::<_, RichLeadReqForCustomer>( + let rows_result = sqlx::query_as::<_, db::models::lead_request::LeadRequest>( r#" - SELECT lr.*, u.full_name as professional_name, u.avatar_url as professional_avatar_url - FROM lead_requests lr - LEFT JOIN professional_profiles pp ON pp.id = lr.professional_id - LEFT JOIN users u ON u.id = pp.user_id - WHERE lr.requirement_id = $1 - ORDER BY lr.requested_at DESC + SELECT * FROM lead_requests + WHERE user_role_profile_id = $1 + ORDER BY requested_at DESC LIMIT $2 OFFSET $3 "# ) - .bind(req.id) + .bind(id) .bind(limit) .bind(offset) .fetch_all(&state.pool) @@ -316,22 +255,11 @@ async fn list_requests( async fn approve_request( State(state): State, - Path((req_id, lead_id)): Path<(Uuid, Uuid)>, + Path(lead_id): Path, auth: AuthUser, ) -> impl IntoResponse { - let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(c)) => c, - _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), - }; - - let req = match RequirementRepository::get_by_id(&state.pool, req_id).await { - Ok(Some(r)) if r.customer_id == customer.id => r, - Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), - _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), - }; - let lead = match LeadRequestRepository::get_by_id(&state.pool, lead_id).await { - Ok(Some(l)) if l.requirement_id == req.id => l, + Ok(Some(l)) => l, _ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), }; @@ -341,15 +269,9 @@ async fn approve_request( match LeadRequestRepository::update_status(&state.pool, lead.id, "ACCEPTED").await { Ok(updated) => { - let prof_user_id = match ProfessionalRepository::get_user_id_by_professional_id(&state.pool, lead.professional_id).await { - Ok(Some(user_id)) => user_id, - Ok(None) => return (StatusCode::NOT_FOUND, "Professional not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - }; - - match ProfessionalRepository::try_debit_reserved_tracecoins( + match TracecoinWalletRepository::try_debit_reserved_tracecoins( &state.pool, - prof_user_id, + lead.user_role_profile_id, lead.tracecoins_reserved, lead.id, ).await { @@ -358,33 +280,8 @@ async fn approve_request( Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } - let req_after = match RequirementRepository::increment_accepted_count_and_get(&state.pool, req.id).await { - Ok(r) => r, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - }; - - if req_after.accepted_count >= 10 && req_after.status != "CLOSED" { - let _ = RequirementRepository::update_status(&state.pool, req.id, "CLOSED").await; - } - - // Send contact-exchange emails to both parties (ignore failures) - let customer_user = UserRepository::get_by_id(&state.pool, auth.user_id).await.ok(); - let professional_user = UserRepository::get_by_id(&state.pool, prof_user_id).await.ok(); - if let (Some(cust), Some(prof)) = (customer_user, professional_user) { - let cust_phone = cust.phone.as_deref().unwrap_or("N/A"); - let prof_phone = prof.phone.as_deref().unwrap_or("N/A"); - let _ = state.mail.send_lead_accepted_professional_email( - &prof.email, prof.full_name.as_deref().unwrap_or("Professional"), cust.full_name.as_deref().unwrap_or("Customer"), &cust.email, cust_phone, - ).await; - let _ = state.mail.send_lead_accepted_customer_email( - &cust.email, cust.full_name.as_deref().unwrap_or("Customer"), prof.full_name.as_deref().unwrap_or("Professional"), &prof.email, prof_phone, - ).await; - } - (StatusCode::OK, Json(serde_json::json!({ "lead_request": updated, - "requirement_status": if req_after.accepted_count >= 10 { "CLOSED" } else { req_after.status.as_str() }, - "accepted_count": req_after.accepted_count, }))).into_response() }, Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), @@ -393,23 +290,12 @@ async fn approve_request( async fn reject_request( State(state): State, - Path((req_id, lead_id)): Path<(Uuid, Uuid)>, - auth: AuthUser, + Path(lead_id): Path, + _auth: AuthUser, Json(_payload): Json, ) -> impl IntoResponse { - let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(c)) => c, - _ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(), - }; - - let req = match RequirementRepository::get_by_id(&state.pool, req_id).await { - Ok(Some(r)) if r.customer_id == customer.id => r, - Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), - _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), - }; - let lead = match LeadRequestRepository::get_by_id(&state.pool, lead_id).await { - Ok(Some(l)) if l.requirement_id == req.id => l, + Ok(Some(l)) => l, _ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), }; @@ -419,15 +305,9 @@ async fn reject_request( match LeadRequestRepository::update_status(&state.pool, lead.id, "REJECTED").await { Ok(updated) => { - let prof_user_id = match ProfessionalRepository::get_user_id_by_professional_id(&state.pool, lead.professional_id).await { - Ok(Some(user_id)) => user_id, - Ok(None) => return (StatusCode::NOT_FOUND, "Professional not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - }; - - match ProfessionalRepository::try_release_reserved_tracecoins( + match TracecoinWalletRepository::try_release_reserved_tracecoins( &state.pool, - prof_user_id, + lead.user_role_profile_id, lead.tracecoins_reserved, lead.id, "LEAD_REJECTED", @@ -437,13 +317,6 @@ async fn reject_request( Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } - // Notify professional their request was rejected (ignore failures) - if let Ok(prof_user) = UserRepository::get_by_id(&state.pool, prof_user_id).await { - let _ = state.mail.send_lead_rejected_email( - &prof_user.email, prof_user.full_name.as_deref().unwrap_or("Professional"), &req.title, - ).await; - } - (StatusCode::OK, Json(updated)).into_response() }, Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), diff --git a/apps/developers/src/admin.rs b/apps/developers/src/admin.rs index e7a43a6..0df6c05 100644 --- a/apps/developers/src/admin.rs +++ b/apps/developers/src/admin.rs @@ -1,5 +1,5 @@ use contracts::ProfessionState; -use db::models::developer::DeveloperProfile; +use db::models::user_role_profile::UserRoleProfile; use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use serde::Serialize; use uuid::Uuid; @@ -7,6 +7,7 @@ use uuid::Uuid; #[derive(Serialize)] pub struct AdminDeveloperList { pub id: Uuid, + pub user_role_profile_id: Uuid, pub user_id: Uuid, pub display_name: Option, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminDeveloperList { pub updated_at: chrono::DateTime, } -impl From for AdminDeveloperList { - fn from(p: DeveloperProfile) -> Self { +impl From for AdminDeveloperList { + fn from(p: UserRoleProfile) -> Self { Self { id: p.id, + user_role_profile_id: p.id, user_id: p.user_id, display_name: p.display_name, bio: p.bio, @@ -40,10 +42,15 @@ pub fn router() -> Router { async fn list_developers( State(state): State, ) -> Result { - let developers = sqlx::query_as::<_, DeveloperProfile>( + let developers = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM developer_profiles + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'developer' ORDER BY created_at DESC LIMIT 100 "#, @@ -60,11 +67,15 @@ async fn get_developer( State(state): State, Path(id): Path, ) -> Result { - let developer = sqlx::query_as::<_, DeveloperProfile>( + let developer = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM developer_profiles - WHERE id = $1 + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'developer' "#, ) .bind(id) diff --git a/apps/developers/src/handlers.rs b/apps/developers/src/handlers.rs index c8f722d..8bd2ab2 100644 --- a/apps/developers/src/handlers.rs +++ b/apps/developers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match DeveloperRepository::upsert(&state.pool, auth.user_id, payload).await { + match DeveloperRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/apps/fitness_trainers/src/admin.rs b/apps/fitness_trainers/src/admin.rs index 415aa46..bac8631 100644 --- a/apps/fitness_trainers/src/admin.rs +++ b/apps/fitness_trainers/src/admin.rs @@ -1,5 +1,5 @@ use contracts::ProfessionState; -use db::models::fitness_trainer::FitnessTrainerProfile; +use db::models::user_role_profile::UserRoleProfile; use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use serde::Serialize; use uuid::Uuid; @@ -7,6 +7,7 @@ use uuid::Uuid; #[derive(Serialize)] pub struct AdminFitnessTrainerList { pub id: Uuid, + pub user_role_profile_id: Uuid, pub user_id: Uuid, pub display_name: Option, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminFitnessTrainerList { pub updated_at: chrono::DateTime, } -impl From for AdminFitnessTrainerList { - fn from(p: FitnessTrainerProfile) -> Self { +impl From for AdminFitnessTrainerList { + fn from(p: UserRoleProfile) -> Self { Self { id: p.id, + user_role_profile_id: p.id, user_id: p.user_id, display_name: p.display_name, bio: p.bio, @@ -40,10 +42,15 @@ pub fn router() -> Router { async fn list_fitness_trainers( State(state): State, ) -> Result { - let trainers = sqlx::query_as::<_, FitnessTrainerProfile>( + let trainers = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM fitness_trainer_profiles + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'fitness_trainer' ORDER BY created_at DESC LIMIT 100 "#, @@ -60,11 +67,15 @@ async fn get_fitness_trainer( State(state): State, Path(id): Path, ) -> Result { - let trainer = sqlx::query_as::<_, FitnessTrainerProfile>( + let trainer = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM fitness_trainer_profiles - WHERE id = $1 + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'fitness_trainer' "#, ) .bind(id) diff --git a/apps/fitness_trainers/src/handlers.rs b/apps/fitness_trainers/src/handlers.rs index 8cae4ee..1630571 100644 --- a/apps/fitness_trainers/src/handlers.rs +++ b/apps/fitness_trainers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match FitnessTrainerRepository::upsert(&state.pool, auth.user_id, payload).await { + match FitnessTrainerRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/apps/graphic_designers/src/admin.rs b/apps/graphic_designers/src/admin.rs index 30c0959..8a75891 100644 --- a/apps/graphic_designers/src/admin.rs +++ b/apps/graphic_designers/src/admin.rs @@ -1,5 +1,5 @@ use contracts::ProfessionState; -use db::models::graphic_designer::GraphicDesignerProfile; +use db::models::user_role_profile::UserRoleProfile; use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use serde::Serialize; use uuid::Uuid; @@ -7,6 +7,7 @@ use uuid::Uuid; #[derive(Serialize)] pub struct AdminGraphicDesignerList { pub id: Uuid, + pub user_role_profile_id: Uuid, pub user_id: Uuid, pub display_name: Option, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminGraphicDesignerList { pub updated_at: chrono::DateTime, } -impl From for AdminGraphicDesignerList { - fn from(p: GraphicDesignerProfile) -> Self { +impl From for AdminGraphicDesignerList { + fn from(p: UserRoleProfile) -> Self { Self { id: p.id, + user_role_profile_id: p.id, user_id: p.user_id, display_name: p.display_name, bio: p.bio, @@ -40,10 +42,15 @@ pub fn router() -> Router { async fn list_graphic_designers( State(state): State, ) -> Result { - let designers = sqlx::query_as::<_, GraphicDesignerProfile>( + let designers = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM graphic_designer_profiles + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'graphic_designer' ORDER BY created_at DESC LIMIT 100 "#, @@ -60,11 +67,15 @@ async fn get_graphic_designer( State(state): State, Path(id): Path, ) -> Result { - let designer = sqlx::query_as::<_, GraphicDesignerProfile>( + let designer = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM graphic_designer_profiles - WHERE id = $1 + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'graphic_designer' "#, ) .bind(id) diff --git a/apps/graphic_designers/src/handlers.rs b/apps/graphic_designers/src/handlers.rs index 49a25db..a880b19 100644 --- a/apps/graphic_designers/src/handlers.rs +++ b/apps/graphic_designers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match GraphicDesignerRepository::upsert(&state.pool, auth.user_id, payload).await { + match GraphicDesignerRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 33c251e..70ac4d6 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -38,7 +38,7 @@ pub struct JobBrowseQuery { #[derive(Deserialize)] pub struct ApplyRequest { - pub cover_letter: Option, + pub cover_note: Option, pub resume_url: Option, } @@ -234,9 +234,8 @@ async fn apply_to_job( let db_payload = CreateApplicationPayload { job_id: job.id, - job_seeker_id: seeker.id, - cover_letter: payload.cover_letter, - resume_url: payload.resume_url.or(seeker.resume_url), + applicant_user_id: auth.user_id, + cover_note: payload.cover_note, }; match ApplicationRepository::create(&state.pool, db_payload).await { @@ -287,7 +286,7 @@ async fn list_my_applications( let page = q.page.unwrap_or(1); let limit = q.limit.unwrap_or(20); - match ApplicationRepository::list_by_job_seeker_id(&state.pool, seeker.id, page, limit).await { + match ApplicationRepository::list_by_user_id(&state.pool, auth.user_id, page, limit).await { Ok(apps) => (StatusCode::OK, Json(serde_json::json!({ "data": apps, "pagination": { "page": page, "limit": limit } @@ -307,7 +306,7 @@ async fn get_my_application( }; match ApplicationRepository::get_by_id(&state.pool, id).await { - Ok(Some(app)) if app.job_seeker_id == seeker.id => (StatusCode::OK, Json(app)).into_response(), + Ok(Some(app)) if app.applicant_user_id == auth.user_id => (StatusCode::OK, Json(app)).into_response(), Ok(Some(_)) => (StatusCode::FORBIDDEN, "Access denied").into_response(), Ok(None) => (StatusCode::NOT_FOUND, "Application not found").into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), @@ -325,7 +324,7 @@ async fn withdraw_application( }; let app = match ApplicationRepository::get_by_id(&state.pool, id).await { - Ok(Some(a)) if a.job_seeker_id == seeker.id => a, + Ok(Some(a)) if a.applicant_user_id == auth.user_id => a, Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(), _ => return (StatusCode::NOT_FOUND, "Application not found").into_response(), }; diff --git a/apps/jobs/src/main.rs b/apps/jobs/src/main.rs index 3cd4ff7..efd6519 100644 --- a/apps/jobs/src/main.rs +++ b/apps/jobs/src/main.rs @@ -1,7 +1,7 @@ use axum::{ extract::State, http::StatusCode, - routing::{get, post, put, delete}, + routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; @@ -16,7 +16,7 @@ pub struct AppState { pub pool: PgPool, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Job { pub id: uuid::Uuid, pub title: String, diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs index 9aac078..1260a91 100644 --- a/apps/leads/src/main.rs +++ b/apps/leads/src/main.rs @@ -1,7 +1,7 @@ use axum::{ extract::State, http::StatusCode, - routing::{get, post, put, delete}, + routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; @@ -16,7 +16,7 @@ pub struct AppState { pub pool: PgPool, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Lead { pub id: uuid::Uuid, pub title: String, @@ -37,7 +37,7 @@ pub struct CreateLead { async fn list_leads(State(state): State>) -> Result>, StatusCode> { let leads = sqlx::query_as::<_, Lead>( - "SELECT id, title, description, location, profession_key, status, created_at FROM requirements ORDER BY created_at DESC" + "SELECT id, title, description, location, profession_key, status, created_at FROM leads ORDER BY created_at DESC" ) .fetch_all(&state.pool) .await @@ -52,7 +52,7 @@ async fn create_lead( ) -> Result, StatusCode> { let lead = sqlx::query_as::<_, Lead>( r#" - INSERT INTO requirements (title, description, location, profession_key) + INSERT INTO leads (title, description, location, profession_key) VALUES ($1, $2, $3, $4) RETURNING id, title, description, location, profession_key, status, created_at "#, @@ -73,7 +73,7 @@ async fn get_lead( axum::extract::Path(id): axum::extract::Path, ) -> Result, StatusCode> { let lead = sqlx::query_as::<_, Lead>( - "SELECT id, title, description, location, profession_key, status, created_at FROM requirements WHERE id = $1" + "SELECT id, title, description, location, profession_key, status, created_at FROM leads WHERE id = $1" ) .bind(id) .fetch_optional(&state.pool) diff --git a/apps/makeup_artists/src/admin.rs b/apps/makeup_artists/src/admin.rs index 704440d..5873212 100644 --- a/apps/makeup_artists/src/admin.rs +++ b/apps/makeup_artists/src/admin.rs @@ -1,5 +1,5 @@ use contracts::ProfessionState; -use db::models::makeup_artist::MakeupArtistProfile; +use db::models::user_role_profile::UserRoleProfile; use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use serde::Serialize; use uuid::Uuid; @@ -7,6 +7,7 @@ use uuid::Uuid; #[derive(Serialize)] pub struct AdminMakeupArtistList { pub id: Uuid, + pub user_role_profile_id: Uuid, pub user_id: Uuid, pub display_name: Option, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminMakeupArtistList { pub updated_at: chrono::DateTime, } -impl From for AdminMakeupArtistList { - fn from(p: MakeupArtistProfile) -> Self { +impl From for AdminMakeupArtistList { + fn from(p: UserRoleProfile) -> Self { Self { id: p.id, + user_role_profile_id: p.id, user_id: p.user_id, display_name: p.display_name, bio: p.bio, @@ -40,10 +42,15 @@ pub fn router() -> Router { async fn list_makeup_artists( State(state): State, ) -> Result { - let artists = sqlx::query_as::<_, MakeupArtistProfile>( + let artists = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM makeup_artist_profiles + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'makeup_artist' ORDER BY created_at DESC LIMIT 100 "#, @@ -60,11 +67,15 @@ async fn get_makeup_artist( State(state): State, Path(id): Path, ) -> Result { - let artist = sqlx::query_as::<_, MakeupArtistProfile>( + let artist = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM makeup_artist_profiles - WHERE id = $1 + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'makeup_artist' "#, ) .bind(id) diff --git a/apps/makeup_artists/src/handlers.rs b/apps/makeup_artists/src/handlers.rs index db25439..bd8f75c 100644 --- a/apps/makeup_artists/src/handlers.rs +++ b/apps/makeup_artists/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match MakeupArtistRepository::upsert(&state.pool, auth.user_id, payload).await { + match MakeupArtistRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/apps/photographers/src/admin.rs b/apps/photographers/src/admin.rs index 2375b0e..0d87a81 100644 --- a/apps/photographers/src/admin.rs +++ b/apps/photographers/src/admin.rs @@ -1,5 +1,5 @@ use contracts::ProfessionState; -use db::models::photographer::PhotographerProfile; +use db::models::user_role_profile::UserRoleProfile; use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use serde::Serialize; use uuid::Uuid; @@ -7,6 +7,7 @@ use uuid::Uuid; #[derive(Serialize)] pub struct AdminPhotographerList { pub id: Uuid, + pub user_role_profile_id: Uuid, pub user_id: Uuid, pub display_name: Option, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminPhotographerList { pub updated_at: chrono::DateTime, } -impl From for AdminPhotographerList { - fn from(p: PhotographerProfile) -> Self { +impl From for AdminPhotographerList { + fn from(p: UserRoleProfile) -> Self { Self { id: p.id, + user_role_profile_id: p.id, user_id: p.user_id, display_name: p.display_name, bio: p.bio, @@ -40,10 +42,15 @@ pub fn router() -> Router { async fn list_photographers( State(state): State, ) -> Result { - let photographers = sqlx::query_as::<_, PhotographerProfile>( + let photographers = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM photographer_profiles + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'photographer' ORDER BY created_at DESC LIMIT 100 "#, @@ -60,11 +67,15 @@ async fn get_photographer( State(state): State, Path(id): Path, ) -> Result { - let photographer = sqlx::query_as::<_, PhotographerProfile>( + let photographer = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM photographer_profiles - WHERE id = $1 + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'photographer' "#, ) .bind(id) diff --git a/apps/photographers/src/handlers.rs b/apps/photographers/src/handlers.rs index 05a1353..dce61b5 100644 --- a/apps/photographers/src/handlers.rs +++ b/apps/photographers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match PhotographerRepository::upsert(&state.pool, auth.user_id, payload).await { + match PhotographerRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/apps/social_media_managers/src/admin.rs b/apps/social_media_managers/src/admin.rs index 8c357d8..02daad2 100644 --- a/apps/social_media_managers/src/admin.rs +++ b/apps/social_media_managers/src/admin.rs @@ -1,5 +1,5 @@ use contracts::ProfessionState; -use db::models::social_media_manager::SocialMediaManagerProfile; +use db::models::user_role_profile::UserRoleProfile; use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use serde::Serialize; use uuid::Uuid; @@ -7,6 +7,7 @@ use uuid::Uuid; #[derive(Serialize)] pub struct AdminSocialMediaManagerList { pub id: Uuid, + pub user_role_profile_id: Uuid, pub user_id: Uuid, pub display_name: Option, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminSocialMediaManagerList { pub updated_at: chrono::DateTime, } -impl From for AdminSocialMediaManagerList { - fn from(p: SocialMediaManagerProfile) -> Self { +impl From for AdminSocialMediaManagerList { + fn from(p: UserRoleProfile) -> Self { Self { id: p.id, + user_role_profile_id: p.id, user_id: p.user_id, display_name: p.display_name, bio: p.bio, @@ -40,10 +42,15 @@ pub fn router() -> Router { async fn list_social_media_managers( State(state): State, ) -> Result { - let managers = sqlx::query_as::<_, SocialMediaManagerProfile>( + let managers = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM social_media_manager_profiles + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'social_media_manager' ORDER BY created_at DESC LIMIT 100 "#, @@ -60,11 +67,15 @@ async fn get_social_media_manager( State(state): State, Path(id): Path, ) -> Result { - let manager = sqlx::query_as::<_, SocialMediaManagerProfile>( + let manager = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM social_media_manager_profiles - WHERE id = $1 + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'social_media_manager' "#, ) .bind(id) diff --git a/apps/social_media_managers/src/handlers.rs b/apps/social_media_managers/src/handlers.rs index b023a08..a7d54bb 100644 --- a/apps/social_media_managers/src/handlers.rs +++ b/apps/social_media_managers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match SocialMediaManagerRepository::upsert(&state.pool, auth.user_id, payload).await { + match SocialMediaManagerRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/apps/tutors/src/admin.rs b/apps/tutors/src/admin.rs index f0ed453..0773ca6 100644 --- a/apps/tutors/src/admin.rs +++ b/apps/tutors/src/admin.rs @@ -1,5 +1,5 @@ use contracts::ProfessionState; -use db::models::tutor::TutorProfile; +use db::models::user_role_profile::UserRoleProfile; use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use serde::Serialize; use uuid::Uuid; @@ -7,6 +7,7 @@ use uuid::Uuid; #[derive(Serialize)] pub struct AdminTutorList { pub id: Uuid, + pub user_role_profile_id: Uuid, pub user_id: Uuid, pub display_name: Option, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminTutorList { pub updated_at: chrono::DateTime, } -impl From for AdminTutorList { - fn from(p: TutorProfile) -> Self { +impl From for AdminTutorList { + fn from(p: UserRoleProfile) -> Self { Self { id: p.id, + user_role_profile_id: p.id, user_id: p.user_id, display_name: p.display_name, bio: p.bio, @@ -43,10 +45,15 @@ pub fn router() -> Router { async fn list_tutors( State(state): State, ) -> Result { - let tutors = sqlx::query_as::<_, TutorProfile>( + let tutors = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM tutor_profiles + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'tutor' ORDER BY created_at DESC LIMIT 100 "#, @@ -63,11 +70,15 @@ async fn get_tutor( State(state): State, Path(id): Path, ) -> Result { - let tutor = sqlx::query_as::<_, TutorProfile>( + let tutor = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM tutor_profiles - WHERE id = $1 + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'tutor' "#, ) .bind(id) diff --git a/apps/tutors/src/handlers.rs b/apps/tutors/src/handlers.rs index 14aac07..87ee758 100644 --- a/apps/tutors/src/handlers.rs +++ b/apps/tutors/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match TutorRepository::upsert(&state.pool, auth.user_id, payload).await { + match TutorRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/apps/ugc_content_creators/src/handlers.rs b/apps/ugc_content_creators/src/handlers.rs index d77ef6e..10c1f2c 100644 --- a/apps/ugc_content_creators/src/handlers.rs +++ b/apps/ugc_content_creators/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match UgcContentCreatorRepository::upsert(&state.pool, auth.user_id, payload).await { + match UgcContentCreatorRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index e716223..bba409e 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -205,11 +205,23 @@ async fn activate_profile_after_final_approval( _ => return Ok(()), }; + let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2", + ) + .bind(user_id) + .bind(&role_key) + .fetch_optional(&state.pool) + .await? + { + Some(id) => id, + None => return Ok(()), + }; + let query = format!( - "UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1", + "UPDATE {} SET verification_status = 'APPROVED', updated_at = NOW() WHERE id = $1", table ); - sqlx::query(&query).bind(user_id).execute(&state.pool).await?; + sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; sqlx::query( "UPDATE users SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1 AND status = 'PENDING'", @@ -267,11 +279,23 @@ async fn reject_profile_after_final_approval( _ => return Ok(()), }; + let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2", + ) + .bind(user_id) + .bind(&role_key) + .fetch_optional(&state.pool) + .await? + { + Some(id) => id, + None => return Ok(()), + }; + let query = format!( - "UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1", + "UPDATE {} SET verification_status = 'REJECTED', updated_at = NOW() WHERE id = $1", table ); - sqlx::query(&query).bind(user_id).execute(&state.pool).await?; + sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { let display = role_key_to_display(&role_key); diff --git a/apps/users/src/handlers/dashboard.rs b/apps/users/src/handlers/dashboard.rs index 2343ee7..04682d3 100644 --- a/apps/users/src/handlers/dashboard.rs +++ b/apps/users/src/handlers/dashboard.rs @@ -28,7 +28,7 @@ async fn get_metrics(State(state): State) -> Json( - "SELECT COUNT(*) FROM requirements WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'", + "SELECT COUNT(*) FROM leads WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'", ) .fetch_one(&state.pool) .await @@ -37,13 +37,7 @@ async fn get_metrics(State(state): State) -> Json( r#" SELECT COUNT(*) FROM ( - SELECT id FROM company_profiles WHERE status = 'PENDING_APPROVAL' - UNION ALL - SELECT id FROM customer_profiles WHERE status = 'PENDING_APPROVAL' - UNION ALL - SELECT id FROM job_seeker_profiles WHERE status = 'PENDING_APPROVAL' - UNION ALL - SELECT id FROM professionals WHERE status = 'PENDING_APPROVAL' + SELECT id FROM user_role_profiles WHERE status = 'PENDING_APPROVAL' ) sub "#, ) @@ -132,9 +126,8 @@ async fn get_metrics(State(state): State) -> Json Result { + if let Some(id) = sqlx::query_scalar::<_, uuid::Uuid>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2"#, + ) + .bind(user_id) + .bind(role_key) + .fetch_optional(pool) + .await? + { + return Ok(id); + } + + sqlx::query_scalar::<_, uuid::Uuid>( + r#" + INSERT INTO user_role_profiles (user_id, role_key, role_id, status) + VALUES ($1, $2, $3, 'DRAFT') + ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW() + RETURNING id + "#, + ) + .bind(user_id) + .bind(role_key) + .bind(role_id) + .fetch_one(pool) + .await +} diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index d69bbbd..d86c494 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -161,12 +161,28 @@ async fn get_profile( }; let query = format!( - r#"SELECT "profileData", verification_status FROM {} WHERE user_id = $1"#, + r#"SELECT "profileData", verification_status FROM {} WHERE id = $1"#, table ); + let user_role_profile_id = match get_user_role_profile_id(&state.pool, auth.user_id, &role_key).await { + Ok(Some(id)) => id, + Ok(None) => { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "role_key": role_key, + "profile_data": null, + "verification_status": "NOT_STARTED", + })), + ) + .into_response(); + } + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + match sqlx::query(&query) - .bind(auth.user_id) + .bind(user_role_profile_id) .fetch_optional(&state.pool) .await { @@ -252,16 +268,21 @@ async fn save_profile( let query = format!( r#" - INSERT INTO {table} (user_id, "profileData", verification_status, updated_at) + INSERT INTO {table} (id, "profileData", verification_status, updated_at) VALUES ($1, $2, 'DRAFT', NOW()) - ON CONFLICT (user_id) DO UPDATE SET + ON CONFLICT (id) DO UPDATE SET "profileData" = EXCLUDED."profileData", updated_at = NOW() "# ); + let user_role_profile_id = match get_or_create_user_role_profile_id(&state.pool, auth.user_id, &role_key).await { + Ok(id) => id, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + match sqlx::query(&query) - .bind(auth.user_id) + .bind(user_role_profile_id) .bind(&input.profile_data) .execute(&state.pool) .await @@ -434,18 +455,8 @@ async fn fetch_saved_profile( }; } - if let Some(table) = role_to_table(role_key) { - let q = format!(r#"SELECT "profileData" FROM {} WHERE user_id = $1"#, table); - if let Ok(Some(row)) = sqlx::query(&q) - .bind(user_id) - .fetch_optional(&state.pool) - .await - { - use sqlx::Row; - return row - .try_get::("profileData") - .unwrap_or(serde_json::Value::Object(Default::default())); - } + if let Some(urp_id) = get_user_role_profile_id(&state.pool, user_id, role_key).await.ok().flatten() { + return fetch_saved_profile_by_urp_id(state, urp_id, role_key).await; } serde_json::Value::Object(Default::default()) @@ -464,16 +475,86 @@ async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, sta return; } + let user_role_profile_id = match get_user_role_profile_id(&state.pool, user_id, role_key).await { + Ok(Some(id)) => id, + Ok(None) => return, + Err(_) => return, + }; + if let Some(table) = role_to_table(role_key) { let q = format!( - "UPDATE {} SET verification_status = $1, submitted_at = NOW(), updated_at = NOW() WHERE user_id = $2", + "UPDATE {} SET verification_status = $1, submitted_at = NOW(), updated_at = NOW() WHERE id = $2", table ); sqlx::query(&q) .bind(status) - .bind(user_id) + .bind(user_role_profile_id) .execute(&state.pool) .await .ok(); } } + +async fn get_user_role_profile_id( + pool: &sqlx::PgPool, + user_id: Uuid, + role_key: &str, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, Uuid>( + r#" + SELECT id FROM user_role_profiles + WHERE user_id = $1 AND role_key = $2 + "#, + ) + .bind(user_id) + .bind(role_key) + .fetch_optional(pool) + .await +} + +async fn get_or_create_user_role_profile_id( + pool: &sqlx::PgPool, + user_id: Uuid, + role_key: &str, +) -> Result { + if let Some(id) = get_user_role_profile_id(pool, user_id, role_key).await? { + return Ok(id); + } + + let role = RoleRepository::get_by_key(pool, role_key).await?; + + sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO user_role_profiles (user_id, role_key, role_id, status) + VALUES ($1, $2, $3, 'DRAFT') + ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW() + RETURNING id + "#, + ) + .bind(user_id) + .bind(role_key) + .bind(role.id) + .fetch_one(pool) + .await +} + +async fn fetch_saved_profile_by_urp_id( + state: &AppState, + user_role_profile_id: Uuid, + role_key: &str, +) -> serde_json::Value { + if let Some(table) = role_to_table(role_key) { + let q = format!(r#"SELECT "profileData" FROM {} WHERE id = $1"#, table); + if let Ok(Some(row)) = sqlx::query(&q) + .bind(user_role_profile_id) + .fetch_optional(&state.pool) + .await + { + use sqlx::Row; + return row + .try_get::("profileData") + .unwrap_or(serde_json::Value::Object(Default::default())); + } + } + serde_json::Value::Object(Default::default()) +} diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index ec96007..e4fad13 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -123,11 +123,23 @@ async fn trigger_rejection( _ => return Ok(()), }; + let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2", + ) + .bind(user_id) + .bind(&role_key) + .fetch_optional(&state.pool) + .await? + { + Some(id) => id, + None => return Ok(()), + }; + let query = format!( - "UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1", + "UPDATE {} SET verification_status = 'REJECTED', updated_at = NOW() WHERE id = $1", table ); - sqlx::query(&query).bind(user_id).execute(&state.pool).await?; + sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; // Send Email if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await { diff --git a/apps/video_editors/src/admin.rs b/apps/video_editors/src/admin.rs index 9853946..e665f55 100644 --- a/apps/video_editors/src/admin.rs +++ b/apps/video_editors/src/admin.rs @@ -1,5 +1,5 @@ use contracts::ProfessionState; -use db::models::video_editor::VideoEditorProfile; +use db::models::user_role_profile::UserRoleProfile; use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use serde::Serialize; use uuid::Uuid; @@ -7,6 +7,7 @@ use uuid::Uuid; #[derive(Serialize)] pub struct AdminVideoEditorList { pub id: Uuid, + pub user_role_profile_id: Uuid, pub user_id: Uuid, pub display_name: Option, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminVideoEditorList { pub updated_at: chrono::DateTime, } -impl From for AdminVideoEditorList { - fn from(p: VideoEditorProfile) -> Self { +impl From for AdminVideoEditorList { + fn from(p: UserRoleProfile) -> Self { Self { id: p.id, + user_role_profile_id: p.id, user_id: p.user_id, display_name: p.display_name, bio: p.bio, @@ -40,10 +42,15 @@ pub fn router() -> Router { async fn list_video_editors( State(state): State, ) -> Result { - let editors = sqlx::query_as::<_, VideoEditorProfile>( + let editors = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM video_editor_profiles + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'video_editor' ORDER BY created_at DESC LIMIT 100 "#, @@ -60,11 +67,15 @@ async fn get_video_editor( State(state): State, Path(id): Path, ) -> Result { - let editor = sqlx::query_as::<_, VideoEditorProfile>( + let editor = sqlx::query_as::<_, UserRoleProfile>( r#" - SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at - FROM video_editor_profiles - WHERE id = $1 + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'video_editor' "#, ) .bind(id) diff --git a/apps/video_editors/src/handlers.rs b/apps/video_editors/src/handlers.rs index 7ddf86f..5b97986 100644 --- a/apps/video_editors/src/handlers.rs +++ b/apps/video_editors/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { - match VideoEditorRepository::upsert(&state.pool, auth.user_id, payload).await { + match VideoEditorRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await { Ok(p) => (StatusCode::OK, Json(p)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 3e4c542..4e75df3 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -9,6 +9,8 @@ use chrono::Utc; use serde::Deserialize; use uuid::Uuid; use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository}; +use db::models::tracecoin_wallet::TracecoinWalletRepository; +use db::models::requirement::RequirementRepository; use db::models::professional::{ CreatePortfolioItemPayload, CreateServicePayload, @@ -16,7 +18,7 @@ use db::models::professional::{ UpdatePortfolioItemPayload, UpdateServicePayload, }; -use db::models::requirement::RequirementRepository; +use db::models::user_role_profile::UserRoleProfileRepository; use crate::auth_middleware::AuthUser; use crate::ProfessionState; @@ -35,7 +37,10 @@ pub struct LeadRequestPayload { /// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`. pub fn shared_routes(profession_key: &'static str) -> Router { Router::new() - .route("/profile/submit", post(submit_for_verification)) + .route("/profile/submit", post({ + let pk = profession_key; + move |state, auth| submit_for_verification(state, auth, pk) + })) // ── Marketplace (Redis-cached) ──────────────────────────────────────── .route( "/marketplace", @@ -129,9 +134,10 @@ async fn send_lead_request( return (StatusCode::TOO_MANY_REQUESTS, "Too many lead requests. Try again later.").into_response(); } - let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(p) => p, - Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, profession_key).await { + Ok(Some(p)) => p, + Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; match is_professional_profile_approved(&state.pool, auth.user_id, profession_key).await { @@ -152,7 +158,7 @@ async fn send_lead_request( // ── Deduplication: one lead per requirement per professional (24 h) ──────── let duplicate = cache::lead::is_duplicate( &mut redis, - &prof.id.to_string(), + &user_role_profile.id.to_string(), &payload.requirement_id.to_string(), ) .await @@ -172,24 +178,23 @@ async fn send_lead_request( return (StatusCode::CONFLICT, "Requirement reached max requests").into_response(); } - let wallet = match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await { + let wallet = match TracecoinWalletRepository::get_by_user_id(&state.pool, auth.user_id).await { Ok(w) => w, Err(_) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(), }; - if wallet.balance < 25 { + if wallet.current_balance < 25 { return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response(); } let db_payload = CreateLeadRequestPayload { - requirement_id: req.id, - professional_id: prof.id, + user_role_profile_id: user_role_profile.id, expires_at: Utc::now() + chrono::Duration::hours(24), }; match LeadRequestRepository::create(&state.pool, db_payload).await { Ok(lead) => { - let reserved = ProfessionalRepository::try_reserve_tracecoins( + let reserved = TracecoinWalletRepository::try_reserve_tracecoins( &state.pool, auth.user_id, lead.tracecoins_reserved, @@ -213,7 +218,7 @@ async fn send_lead_request( // Mark dedup in Redis so this professional can't spam the same requirement let _ = cache::lead::mark_sent( &mut redis, - &prof.id.to_string(), + &user_role_profile.id.to_string(), &payload.requirement_id.to_string(), ) .await; @@ -427,9 +432,10 @@ async fn cancel_request( auth: AuthUser, Path(id): Path, ) -> impl IntoResponse { - let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(p) => p, - Err(_) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { + Ok(Some(p)) => p, + Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; let lead = match LeadRequestRepository::get_by_id(&state.pool, id).await { @@ -438,7 +444,7 @@ async fn cancel_request( Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; - if lead.professional_id != prof.id { + if lead.user_role_profile_id != user_role_profile.id { return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Access denied" }))).into_response(); } @@ -450,7 +456,7 @@ async fn cancel_request( // Release reserved Tracecoins back to balance if lead.tracecoins_reserved > 0 { - let _ = ProfessionalRepository::try_release_reserved_tracecoins( + let _ = TracecoinWalletRepository::try_release_reserved_tracecoins( &state.pool, auth.user_id, lead.tracecoins_reserved, @@ -470,51 +476,42 @@ async fn accepted_leads( auth: AuthUser, Query(q): Query, ) -> impl IntoResponse { - let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(p) => p, - Err(_) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { + Ok(Some(p)) => p, + Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; let page = q.page.unwrap_or(1).max(1); let limit = q.limit.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * limit; - // Join lead_requests → requirements → customers → users to get full contact info let rows = sqlx::query( r#" SELECT - lr.id AS lead_id, + lr.id AS lead_request_id, lr.status, lr.requested_at, lr.resolved_at, - r.id AS requirement_id, - r.title AS requirement_title, - r.description AS requirement_description, - r.location AS requirement_location, - r.profession_key, - u.full_name AS customer_name, - u.email AS customer_email, - u.phone AS customer_phone + lr.tracecoins_reserved, + lr.user_role_profile_id FROM lead_requests lr - INNER JOIN requirements r ON r.id = lr.requirement_id - INNER JOIN customers c ON c.id = r.customer_id - INNER JOIN users u ON u.id = c.user_id - WHERE lr.professional_id = $1 + WHERE lr.user_role_profile_id = $1 AND lr.status = 'ACCEPTED' ORDER BY lr.resolved_at DESC LIMIT $2 OFFSET $3 "# ) - .bind(prof.id) + .bind(user_role_profile.id) .bind(limit) .bind(offset) .fetch_all(&state.pool) .await; let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM lead_requests WHERE professional_id = $1 AND status = 'ACCEPTED'" + "SELECT COUNT(*) FROM lead_requests WHERE user_role_profile_id = $1 AND status = 'ACCEPTED'" ) - .bind(prof.id) + .bind(user_role_profile.id) .fetch_one(&state.pool) .await .unwrap_or(0); @@ -524,20 +521,12 @@ async fn accepted_leads( use sqlx::Row; let data: Vec = rows.iter().map(|row| { serde_json::json!({ - "lead_id": row.get::("lead_id"), - "status": row.get::("status"), - "requested_at": row.get::, _>("requested_at"), + "lead_request_id": row.get::("lead_request_id"), + "status": row.get::("status"), + "requested_at": row.get::, _>("requested_at"), "resolved_at": row.try_get::, _>("resolved_at").ok(), - "requirement_id": row.get::("requirement_id"), - "requirement_title": row.get::("requirement_title"), - "requirement_description": row.try_get::("requirement_description").ok(), - "requirement_location": row.try_get::("requirement_location").ok(), - "profession_key": row.get::("profession_key"), - "customer": { - "name": row.try_get::("customer_name").ok(), - "email": row.get::("customer_email"), - "phone": row.try_get::("customer_phone").ok(), - } + "tracecoins_reserved": row.get::("tracecoins_reserved"), + "user_role_profile_id": row.get::("user_role_profile_id"), }) }).collect(); @@ -779,6 +768,7 @@ async fn wallet_invoice_detail( async fn submit_for_verification( State(state): State, auth: AuthUser, + profession_key: &'static str, ) -> impl IntoResponse { let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { Ok(p) => p, @@ -789,7 +779,7 @@ async fn submit_for_verification( return (StatusCode::BAD_REQUEST, format!("Profile is already {}", prof.status)).into_response(); } - match ProfessionalRepository::submit_for_verification(&state.pool, auth.user_id).await { + match ProfessionalRepository::submit_for_verification(&state.pool, auth.user_id, profession_key).await { Ok(profile) => (StatusCode::OK, Json(serde_json::json!({ "status": profile.status, "message": "Profile submitted for verification" diff --git a/crates/db-migrate/src/main.rs b/crates/db-migrate/src/main.rs index a250a20..50faf88 100644 --- a/crates/db-migrate/src/main.rs +++ b/crates/db-migrate/src/main.rs @@ -1,5 +1,6 @@ use std::path::Path; use anyhow::{Context, Result}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] async fn main() -> Result<()> { diff --git a/crates/db/src/models/application.rs b/crates/db/src/models/application.rs index 136573e..f2d27d7 100644 --- a/crates/db/src/models/application.rs +++ b/crates/db/src/models/application.rs @@ -7,21 +7,18 @@ use uuid::Uuid; pub struct Application { pub id: Uuid, pub job_id: Uuid, - pub job_seeker_id: Uuid, - pub cover_letter: Option, - pub resume_url: Option, - pub status: String, // APPLIED, SHORTLISTED, INTERVIEW, OFFERED, HIRED, REJECTED, WITHDRAWN + pub applicant_user_id: Uuid, + pub cover_note: Option, + pub status: String, pub applied_at: DateTime, pub updated_at: DateTime, - pub contact_viewed: bool, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateApplicationPayload { pub job_id: Uuid, - pub job_seeker_id: Uuid, - pub cover_letter: Option, - pub resume_url: Option, + pub applicant_user_id: Uuid, + pub cover_note: Option, } pub struct ApplicationRepository; @@ -33,15 +30,14 @@ impl ApplicationRepository { ) -> Result { let app = sqlx::query_as::<_, Application>( r#" - INSERT INTO applications (job_id, job_seeker_id, cover_letter, resume_url) - VALUES ($1, $2, $3, $4) + INSERT INTO job_applications (job_id, applicant_user_id, cover_note) + VALUES ($1, $2, $3) RETURNING * "#, ) .bind(payload.job_id) - .bind(payload.job_seeker_id) - .bind(payload.cover_letter) - .bind(payload.resume_url) + .bind(payload.applicant_user_id) + .bind(payload.cover_note) .fetch_one(pool) .await?; @@ -49,7 +45,7 @@ impl ApplicationRepository { } pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { - sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE id = $1") + sqlx::query_as::<_, Application>("SELECT * FROM job_applications WHERE id = $1") .bind(id) .fetch_optional(pool) .await @@ -65,7 +61,7 @@ impl ApplicationRepository { let offset = (page - 1) * limit; let apps = sqlx::query_as::<_, Application>( r#" - SELECT * FROM applications + SELECT * FROM job_applications WHERE job_id = $1 AND ($2::VARCHAR IS NULL OR status = $2) ORDER BY applied_at DESC LIMIT $3 OFFSET $4 @@ -81,22 +77,22 @@ impl ApplicationRepository { Ok(apps) } - pub async fn list_by_job_seeker_id( + pub async fn list_by_user_id( pool: &PgPool, - job_seeker_id: Uuid, + applicant_user_id: Uuid, page: i64, limit: i64, ) -> Result, sqlx::Error> { let offset = (page - 1) * limit; let apps = sqlx::query_as::<_, Application>( r#" - SELECT * FROM applications - WHERE job_seeker_id = $1 + SELECT * FROM job_applications + WHERE applicant_user_id = $1 ORDER BY applied_at DESC LIMIT $2 OFFSET $3 "#, ) - .bind(job_seeker_id) + .bind(applicant_user_id) .bind(limit) .bind(offset) .fetch_all(pool) @@ -110,7 +106,7 @@ impl ApplicationRepository { status: &str, ) -> Result { let app = sqlx::query_as::<_, Application>( - "UPDATE applications SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", + "UPDATE job_applications SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", ) .bind(status) .bind(id) @@ -118,14 +114,4 @@ impl ApplicationRepository { .await?; Ok(app) } - - pub async fn mark_contact_viewed(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { - sqlx::query( - "UPDATE applications SET contact_viewed = true WHERE id = $1", - ) - .bind(id) - .execute(pool) - .await?; - Ok(()) - } } diff --git a/crates/db/src/models/catering_service.rs b/crates/db/src/models/catering_service.rs index 1f2e930..1145ce2 100644 --- a/crates/db/src/models/catering_service.rs +++ b/crates/db/src/models/catering_service.rs @@ -34,6 +34,60 @@ pub struct UpsertCateringServiceProfilePayload { pub struct CateringServiceRepository; impl CateringServiceRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, CateringServiceProfile>( + r#"SELECT csp.id, csp.user_role_profile_id, csp.business_name, csp.cuisine_types, csp.event_types, + csp.min_guests, csp.max_guests, csp.has_setup_team, csp.has_serving_staff, + csp.price_per_head_inr, csp.created_at, csp.updated_at + FROM catering_service_profiles csp + INNER JOIN user_role_profiles urp ON urp.id = csp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'catering_service'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'catering_service'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, CateringServiceProfile>( + r#"INSERT INTO catering_service_profiles (user_role_profile_id, business_name, cuisine_types, event_types, + min_guests, max_guests, has_setup_team, has_serving_staff, price_per_head_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + business_name = EXCLUDED.business_name, + cuisine_types = COALESCE(EXCLUDED.cuisine_types, catering_service_profiles.cuisine_types), + event_types = COALESCE(EXCLUDED.event_types, catering_service_profiles.event_types), + min_guests = EXCLUDED.min_guests, + max_guests = EXCLUDED.max_guests, + has_setup_team = EXCLUDED.has_setup_team, + has_serving_staff = EXCLUDED.has_serving_staff, + price_per_head_inr = EXCLUDED.price_per_head_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, business_name, cuisine_types, event_types, + min_guests, max_guests, has_setup_team, has_serving_staff, + price_per_head_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.business_name) + .bind(&p.cuisine_types) + .bind(&p.event_types) + .bind(p.min_guests) + .bind(p.max_guests) + .bind(p.has_setup_team) + .bind(p.has_serving_staff) + .bind(p.price_per_head_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, CateringServiceProfile>( r#"SELECT id, user_role_profile_id, business_name, cuisine_types, event_types, diff --git a/crates/db/src/models/developer.rs b/crates/db/src/models/developer.rs index ece2fdc..d51d1be 100644 --- a/crates/db/src/models/developer.rs +++ b/crates/db/src/models/developer.rs @@ -28,6 +28,53 @@ pub struct UpsertDeveloperProfilePayload { pub struct DeveloperRepository; impl DeveloperRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, DeveloperProfile>( + r#"SELECT dp.id, dp.user_role_profile_id, dp.tech_stack, dp.experience_years, dp.availability, + dp.hourly_rate_inr, dp.remote_ok, + dp.created_at, dp.updated_at + FROM developer_profiles dp + INNER JOIN user_role_profiles urp ON urp.id = dp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'developer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'developer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, DeveloperProfile>( + r#"INSERT INTO developer_profiles (user_role_profile_id, tech_stack, experience_years, + availability, hourly_rate_inr, remote_ok) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + tech_stack = COALESCE(EXCLUDED.tech_stack, developer_profiles.tech_stack), + experience_years = EXCLUDED.experience_years, + availability = EXCLUDED.availability, + hourly_rate_inr = EXCLUDED.hourly_rate_inr, + remote_ok = EXCLUDED.remote_ok, + updated_at = NOW() + RETURNING id, user_role_profile_id, tech_stack, experience_years, availability, + hourly_rate_inr, remote_ok, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.tech_stack) + .bind(p.experience_years) + .bind(&p.availability) + .bind(p.hourly_rate_inr) + .bind(p.remote_ok) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, DeveloperProfile>( r#"SELECT id, user_role_profile_id, tech_stack, experience_years, availability, diff --git a/crates/db/src/models/fitness_trainer.rs b/crates/db/src/models/fitness_trainer.rs index a97fde8..0668ce9 100644 --- a/crates/db/src/models/fitness_trainer.rs +++ b/crates/db/src/models/fitness_trainer.rs @@ -30,6 +30,55 @@ pub struct UpsertFitnessTrainerProfilePayload { pub struct FitnessTrainerRepository; impl FitnessTrainerRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, FitnessTrainerProfile>( + r#"SELECT ftp.id, ftp.user_role_profile_id, ftp.disciplines, ftp.certifications, ftp.online_sessions, + ftp.home_visits, ftp.gym_based, ftp.per_session_rate_inr, + ftp.created_at, ftp.updated_at + FROM fitness_trainer_profiles ftp + INNER JOIN user_role_profiles urp ON urp.id = ftp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'fitness_trainer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'fitness_trainer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, FitnessTrainerProfile>( + r#"INSERT INTO fitness_trainer_profiles (user_role_profile_id, disciplines, certifications, + online_sessions, home_visits, gym_based, per_session_rate_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + disciplines = COALESCE(EXCLUDED.disciplines, fitness_trainer_profiles.disciplines), + certifications = COALESCE(EXCLUDED.certifications, fitness_trainer_profiles.certifications), + online_sessions = EXCLUDED.online_sessions, + home_visits = EXCLUDED.home_visits, + gym_based = EXCLUDED.gym_based, + per_session_rate_inr = EXCLUDED.per_session_rate_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, disciplines, certifications, online_sessions, + home_visits, gym_based, per_session_rate_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.disciplines) + .bind(&p.certifications) + .bind(p.online_sessions) + .bind(p.home_visits) + .bind(p.gym_based) + .bind(p.per_session_rate_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, FitnessTrainerProfile>( r#"SELECT id, user_role_profile_id, disciplines, certifications, online_sessions, diff --git a/crates/db/src/models/graphic_designer.rs b/crates/db/src/models/graphic_designer.rs index 8135da0..5fc945b 100644 --- a/crates/db/src/models/graphic_designer.rs +++ b/crates/db/src/models/graphic_designer.rs @@ -26,6 +26,51 @@ pub struct UpsertGraphicDesignerProfilePayload { pub struct GraphicDesignerRepository; impl GraphicDesignerRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, GraphicDesignerProfile>( + r#"SELECT gdp.id, gdp.user_role_profile_id, gdp.design_tools, gdp.style_tags, + gdp.brand_experience, gdp.starting_price_inr, + gdp.created_at, gdp.updated_at + FROM graphic_designer_profiles gdp + INNER JOIN user_role_profiles urp ON urp.id = gdp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'graphic_designer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'graphic_designer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, GraphicDesignerProfile>( + r#"INSERT INTO graphic_designer_profiles (user_role_profile_id, design_tools, style_tags, + brand_experience, starting_price_inr) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + design_tools = COALESCE(EXCLUDED.design_tools, graphic_designer_profiles.design_tools), + style_tags = COALESCE(EXCLUDED.style_tags, graphic_designer_profiles.style_tags), + brand_experience = EXCLUDED.brand_experience, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, design_tools, style_tags, brand_experience, + starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.design_tools) + .bind(&p.style_tags) + .bind(p.brand_experience) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, GraphicDesignerProfile>( r#"SELECT id, user_role_profile_id, design_tools, style_tags, brand_experience, diff --git a/crates/db/src/models/lead_request.rs b/crates/db/src/models/lead_request.rs index 9de8a4a..c8b1d0c 100644 --- a/crates/db/src/models/lead_request.rs +++ b/crates/db/src/models/lead_request.rs @@ -6,21 +6,19 @@ use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct LeadRequest { pub id: Uuid, - pub requirement_id: Uuid, - pub professional_id: Uuid, - pub status: String, // PENDING, ACCEPTED, REJECTED, EXPIRED, CANCELLED + pub user_role_profile_id: Uuid, + pub status: String, pub tracecoins_reserved: i32, pub expires_at: DateTime, pub requested_at: DateTime, pub resolved_at: Option>, - pub professional_user_id: Option, + pub remarks: Option, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateLeadRequestPayload { - pub requirement_id: Uuid, - pub professional_id: Uuid, + pub user_role_profile_id: Uuid, pub expires_at: DateTime, } @@ -33,13 +31,12 @@ impl LeadRequestRepository { ) -> Result { let req = sqlx::query_as::<_, LeadRequest>( r#" - INSERT INTO lead_requests (requirement_id, professional_id, expires_at) - VALUES ($1, $2, $3) + INSERT INTO lead_requests (user_role_profile_id, expires_at) + VALUES ($1, $2) RETURNING * "#, ) - .bind(payload.requirement_id) - .bind(payload.professional_id) + .bind(payload.user_role_profile_id) .bind(payload.expires_at) .fetch_one(pool) .await?; @@ -54,9 +51,9 @@ impl LeadRequestRepository { .await } - pub async fn list_by_requirement_id( + pub async fn list_by_user_role_profile_id( pool: &PgPool, - requirement_id: Uuid, + user_role_profile_id: Uuid, page: i64, limit: i64, ) -> Result, sqlx::Error> { @@ -64,12 +61,12 @@ impl LeadRequestRepository { let reqs = sqlx::query_as::<_, LeadRequest>( r#" SELECT * FROM lead_requests - WHERE requirement_id = $1 + WHERE user_role_profile_id = $1 ORDER BY requested_at DESC LIMIT $2 OFFSET $3 "#, ) - .bind(requirement_id) + .bind(user_role_profile_id) .bind(limit) .bind(offset) .fetch_all(pool) diff --git a/crates/db/src/models/makeup_artist.rs b/crates/db/src/models/makeup_artist.rs index 9230e78..908c491 100644 --- a/crates/db/src/models/makeup_artist.rs +++ b/crates/db/src/models/makeup_artist.rs @@ -28,6 +28,54 @@ pub struct UpsertMakeupArtistProfilePayload { pub struct MakeupArtistRepository; impl MakeupArtistRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, MakeupArtistProfile>( + r#"SELECT map.id, map.user_role_profile_id, map.specializations, map.kit_brands, + map.home_service, map.studio_available, map.starting_price_inr, + map.created_at, map.updated_at + FROM makeup_artist_profiles map + INNER JOIN user_role_profiles urp ON urp.id = map.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'makeup_artist'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'makeup_artist'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, MakeupArtistProfile>( + r#"INSERT INTO makeup_artist_profiles (user_role_profile_id, specializations, kit_brands, + home_service, studio_available, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + specializations = COALESCE(EXCLUDED.specializations, makeup_artist_profiles.specializations), + kit_brands = COALESCE(EXCLUDED.kit_brands, makeup_artist_profiles.kit_brands), + home_service = EXCLUDED.home_service, + studio_available = EXCLUDED.studio_available, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, specializations, kit_brands, + home_service, studio_available, starting_price_inr, + created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.specializations) + .bind(&p.kit_brands) + .bind(p.home_service) + .bind(p.studio_available) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, MakeupArtistProfile>( r#"SELECT id, user_role_profile_id, specializations, kit_brands, diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 5e40ef3..b8ae833 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -26,4 +26,5 @@ pub mod department; pub mod designation; pub mod verification; pub mod user_role_profile; +pub mod tracecoin_wallet; diff --git a/crates/db/src/models/photographer.rs b/crates/db/src/models/photographer.rs index 229ad7c..9eda354 100644 --- a/crates/db/src/models/photographer.rs +++ b/crates/db/src/models/photographer.rs @@ -30,6 +30,57 @@ pub struct UpsertPhotographerProfilePayload { pub struct PhotographerRepository; impl PhotographerRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, PhotographerProfile>( + r#"SELECT pp.id, pp.user_role_profile_id, pp.specialties, pp.camera_brands, + pp.studio_available, pp.outdoor_shoots, pp.travel_radius_km, + pp.starting_price_inr, + pp.created_at, pp.updated_at + FROM photographer_profiles pp + INNER JOIN user_role_profiles urp ON urp.id = pp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'photographer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertPhotographerProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'photographer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, PhotographerProfile>( + r#"INSERT INTO photographer_profiles (user_role_profile_id, specialties, camera_brands, + studio_available, outdoor_shoots, travel_radius_km, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + specialties = COALESCE(EXCLUDED.specialties, photographer_profiles.specialties), + camera_brands = COALESCE(EXCLUDED.camera_brands, photographer_profiles.camera_brands), + studio_available = EXCLUDED.studio_available, + outdoor_shoots = EXCLUDED.outdoor_shoots, + travel_radius_km = EXCLUDED.travel_radius_km, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, specialties, camera_brands, + studio_available, outdoor_shoots, travel_radius_km, + starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.specialties) + .bind(&p.camera_brands) + .bind(p.studio_available) + .bind(p.outdoor_shoots) + .bind(p.travel_radius_km) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, PhotographerProfile>( r#"SELECT id, user_role_profile_id, specialties, camera_brands, diff --git a/crates/db/src/models/professional.rs b/crates/db/src/models/professional.rs index 9cd8d31..022c760 100644 --- a/crates/db/src/models/professional.rs +++ b/crates/db/src/models/professional.rs @@ -7,11 +7,7 @@ use uuid::Uuid; pub struct Professional { pub id: Uuid, pub user_id: Uuid, - pub profession_key: String, - pub display_name: String, - pub location: Option, - pub bio: Option, - pub extra_data_json: Option, + pub role_key: String, pub status: String, pub created_at: DateTime, pub updated_at: DateTime, @@ -22,20 +18,19 @@ use super::requirement::Requirement; #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct PortfolioItem { pub id: Uuid, - pub professional_id: Uuid, + pub user_role_profile_id: Uuid, pub title: String, pub description: Option, pub tags: Option>, + pub display_order: Option, pub created_at: DateTime, pub updated_at: DateTime, - pub user_id: Option, - pub profession_key: Option, } #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct Service { pub id: Uuid, - pub professional_id: Uuid, + pub user_role_profile_id: Uuid, pub name: String, pub description: Option, pub price: i32, @@ -43,8 +38,6 @@ pub struct Service { pub is_active: bool, pub created_at: DateTime, pub updated_at: DateTime, - pub user_id: Option, - pub profession_key: Option, } #[derive(Debug, Serialize, Deserialize, FromRow)] @@ -117,7 +110,7 @@ pub struct ProfessionalRepository; impl ProfessionalRepository { pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result { sqlx::query_as::<_, Professional>( - "SELECT * FROM professionals WHERE user_id = $1", + "SELECT id, user_id, role_key as profession_key, status, created_at, updated_at FROM user_role_profiles WHERE user_id = $1 AND role_key != 'CUSTOMER' AND role_key != 'COMPANY'", ) .bind(user_id) .fetch_one(pool) @@ -133,7 +126,7 @@ impl ProfessionalRepository { let offset = (page - 1) * limit; sqlx::query_as::<_, Requirement>( r#" - SELECT * FROM requirements + SELECT * FROM leads WHERE profession_key = $1 AND status = 'OPEN' AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -146,20 +139,20 @@ impl ProfessionalRepository { .await } - pub async fn get_portfolio(pool: &PgPool, professional_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_portfolio(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, PortfolioItem>( - "SELECT * FROM portfolio_items WHERE professional_id = $1 ORDER BY created_at DESC", + "SELECT * FROM portfolio_items WHERE user_role_profile_id = $1 ORDER BY display_order, created_at DESC", ) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_all(pool) .await } - pub async fn get_services(pool: &PgPool, professional_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_services(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, Service>( - "SELECT * FROM services WHERE professional_id = $1 AND is_active = true ORDER BY name ASC", + "SELECT * FROM services WHERE user_role_profile_id = $1 AND is_active = true ORDER BY name ASC", ) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_all(pool) .await } @@ -187,14 +180,14 @@ impl ProfessionalRepository { Ok(()) } - pub async fn get_user_id_by_professional_id( + pub async fn get_user_id_by_user_role_profile_id( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, ) -> Result, sqlx::Error> { let row = sqlx::query_scalar::<_, Uuid>( - "SELECT user_id FROM professionals WHERE id = $1", + "SELECT user_id FROM user_role_profiles WHERE id = $1", ) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await?; Ok(row) @@ -202,17 +195,17 @@ impl ProfessionalRepository { pub async fn create_portfolio_item( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, payload: CreatePortfolioItemPayload, ) -> Result { sqlx::query_as::<_, PortfolioItem>( r#" - INSERT INTO portfolio_items (professional_id, title, description, tags) + INSERT INTO portfolio_items (user_role_profile_id, title, description, tags) VALUES ($1, $2, $3, COALESCE($4::text[], '{}')) RETURNING * "#, ) - .bind(professional_id) + .bind(user_role_profile_id) .bind(payload.title) .bind(payload.description) .bind(payload.tags) @@ -222,7 +215,7 @@ impl ProfessionalRepository { pub async fn update_portfolio_item( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, id: Uuid, payload: UpdatePortfolioItemPayload, ) -> Result, sqlx::Error> { @@ -234,7 +227,7 @@ impl ProfessionalRepository { description = COALESCE($2, description), tags = COALESCE($3, tags), updated_at = NOW() - WHERE id = $4 AND professional_id = $5 + WHERE id = $4 AND user_role_profile_id = $5 RETURNING * "#, ) @@ -242,7 +235,7 @@ impl ProfessionalRepository { .bind(payload.description) .bind(payload.tags) .bind(id) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await?; Ok(row) @@ -250,14 +243,14 @@ impl ProfessionalRepository { pub async fn delete_portfolio_item( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, id: Uuid, ) -> Result { let result = sqlx::query( - "DELETE FROM portfolio_items WHERE id = $1 AND professional_id = $2", + "DELETE FROM portfolio_items WHERE id = $1 AND user_role_profile_id = $2", ) .bind(id) - .bind(professional_id) + .bind(user_role_profile_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) @@ -265,17 +258,17 @@ impl ProfessionalRepository { pub async fn create_service( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, payload: CreateServicePayload, ) -> Result { sqlx::query_as::<_, Service>( r#" - INSERT INTO services (professional_id, name, description, price, duration_minutes) + INSERT INTO services (user_role_profile_id, name, description, price, duration_minutes) VALUES ($1, $2, $3, $4, $5) RETURNING * "#, ) - .bind(professional_id) + .bind(user_role_profile_id) .bind(payload.name) .bind(payload.description) .bind(payload.price) @@ -286,7 +279,7 @@ impl ProfessionalRepository { pub async fn update_service( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, id: Uuid, payload: UpdateServicePayload, ) -> Result, sqlx::Error> { @@ -300,7 +293,7 @@ impl ProfessionalRepository { duration_minutes = COALESCE($4, duration_minutes), is_active = COALESCE($5, is_active), updated_at = NOW() - WHERE id = $6 AND professional_id = $7 + WHERE id = $6 AND user_role_profile_id = $7 RETURNING * "#, ) @@ -310,7 +303,7 @@ impl ProfessionalRepository { .bind(payload.duration_minutes) .bind(payload.is_active) .bind(id) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await?; Ok(row) @@ -318,12 +311,12 @@ impl ProfessionalRepository { pub async fn delete_service( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, id: Uuid, ) -> Result { - let result = sqlx::query("DELETE FROM services WHERE id = $1 AND professional_id = $2") + let result = sqlx::query("DELETE FROM services WHERE id = $1 AND user_role_profile_id = $2") .bind(id) - .bind(professional_id) + .bind(user_role_profile_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) @@ -560,11 +553,13 @@ impl ProfessionalRepository { pub async fn submit_for_verification( pool: &PgPool, user_id: Uuid, + profession_key: &str, ) -> Result { let prof = sqlx::query_as::<_, Professional>( - "SELECT * FROM professionals WHERE user_id = $1", + "SELECT id, user_id, role_key as profession_key, status, created_at, updated_at FROM user_role_profiles WHERE user_id = $1 AND role_key = $2", ) .bind(user_id) + .bind(profession_key) .fetch_one(pool) .await?; @@ -574,13 +569,14 @@ impl ProfessionalRepository { let prof = sqlx::query_as::<_, Professional>( r#" - UPDATE professionals + UPDATE user_role_profiles SET status = 'PENDING_REVIEW', updated_at = NOW() - WHERE user_id = $1 - RETURNING * + WHERE user_id = $1 AND role_key = $2 + RETURNING id, user_id, role_key as profession_key, status, created_at, updated_at "#, ) .bind(user_id) + .bind(profession_key) .fetch_one(pool) .await?; diff --git a/crates/db/src/models/requirement.rs b/crates/db/src/models/requirement.rs index 9898161..b0912d5 100644 --- a/crates/db/src/models/requirement.rs +++ b/crates/db/src/models/requirement.rs @@ -6,7 +6,6 @@ use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct Requirement { pub id: Uuid, - pub customer_id: Uuid, pub profession_key: String, pub title: String, pub description: String, @@ -14,7 +13,7 @@ pub struct Requirement { pub budget: Option, pub preferred_date: Option, pub extra_data_json: Option, - pub status: String, // DRAFT, PENDING_APPROVAL, OPEN, CLOSED, EXPIRED, REJECTED + pub status: String, pub rejection_reason: Option, pub request_count: i32, pub accepted_count: i32, @@ -22,12 +21,13 @@ pub struct Requirement { pub approved_at: Option>, pub approved_by: Option, pub created_at: DateTime, + pub created_by_user_id: Option, + pub required_date: Option, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateRequirementPayload { - pub customer_id: Uuid, pub profession_key: String, pub title: String, pub description: String, @@ -56,15 +56,14 @@ impl RequirementRepository { ) -> Result { let req = sqlx::query_as::<_, Requirement>( r#" - INSERT INTO requirements ( - customer_id, profession_key, title, description, location, + INSERT INTO leads ( + profession_key, title, description, location, budget, preferred_date, extra_data_json ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * "#, ) - .bind(payload.customer_id) .bind(payload.profession_key) .bind(payload.title) .bind(payload.description) @@ -79,28 +78,28 @@ impl RequirementRepository { } pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { - sqlx::query_as::<_, Requirement>("SELECT * FROM requirements WHERE id = $1") + sqlx::query_as::<_, Requirement>("SELECT * FROM leads WHERE id = $1") .bind(id) .fetch_optional(pool) .await } - pub async fn list_by_customer_id( + pub async fn list_by_user_id( pool: &PgPool, - customer_id: Uuid, + user_id: Uuid, page: i64, limit: i64, ) -> Result, sqlx::Error> { let offset = (page - 1) * limit; let reqs = sqlx::query_as::<_, Requirement>( r#" - SELECT * FROM requirements - WHERE customer_id = $1 + SELECT * FROM leads + WHERE created_by_user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 "#, ) - .bind(customer_id) + .bind(user_id) .bind(limit) .bind(offset) .fetch_all(pool) @@ -116,7 +115,7 @@ impl RequirementRepository { ) -> Result { let req = sqlx::query_as::<_, Requirement>( r#" - UPDATE requirements SET + UPDATE leads SET title = COALESCE($1, title), description = COALESCE($2, description), location = COALESCE($3, location), @@ -147,7 +146,7 @@ impl RequirementRepository { status: &str, ) -> Result { let req = sqlx::query_as::<_, Requirement>( - "UPDATE requirements SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", + "UPDATE leads SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", ) .bind(status) .bind(id) @@ -157,7 +156,7 @@ impl RequirementRepository { } pub async fn increment_request_count(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { - sqlx::query("UPDATE requirements SET request_count = request_count + 1 WHERE id = $1") + sqlx::query("UPDATE leads SET request_count = request_count + 1 WHERE id = $1") .bind(id) .execute(pool) .await?; @@ -166,7 +165,7 @@ impl RequirementRepository { pub async fn increment_accepted_count(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { sqlx::query( - "UPDATE requirements SET accepted_count = accepted_count + 1 WHERE id = $1", + "UPDATE leads SET accepted_count = accepted_count + 1 WHERE id = $1", ) .bind(id) .execute(pool) @@ -180,7 +179,7 @@ impl RequirementRepository { ) -> Result { sqlx::query_as::<_, Requirement>( r#" - UPDATE requirements + UPDATE leads SET accepted_count = accepted_count + 1, updated_at = NOW() WHERE id = $1 RETURNING * @@ -198,7 +197,7 @@ impl RequirementRepository { ) -> Result { sqlx::query_as::<_, Requirement>( r#" - UPDATE requirements + UPDATE leads SET status = 'OPEN', approved_at = NOW(), approved_by = $1, rejection_reason = NULL, updated_at = NOW() WHERE id = $2 RETURNING * @@ -217,7 +216,7 @@ impl RequirementRepository { ) -> Result { sqlx::query_as::<_, Requirement>( r#" - UPDATE requirements + UPDATE leads SET status = 'REJECTED', rejection_reason = $1, approved_at = NULL, approved_by = NULL, updated_at = NOW() WHERE id = $2 RETURNING * diff --git a/crates/db/src/models/social_media_manager.rs b/crates/db/src/models/social_media_manager.rs index 2da8416..2163bba 100644 --- a/crates/db/src/models/social_media_manager.rs +++ b/crates/db/src/models/social_media_manager.rs @@ -28,6 +28,53 @@ pub struct UpsertSocialMediaManagerProfilePayload { pub struct SocialMediaManagerRepository; impl SocialMediaManagerRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, SocialMediaManagerProfile>( + r#"SELECT smmp.id, smmp.user_role_profile_id, smmp.platforms, smmp.industries, smmp.content_types, + smmp.avg_follower_growth_pct, smmp.starting_price_inr, + smmp.created_at, smmp.updated_at + FROM social_media_manager_profiles smmp + INNER JOIN user_role_profiles urp ON urp.id = smmp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'social_media_manager'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'social_media_manager'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, SocialMediaManagerProfile>( + r#"INSERT INTO social_media_manager_profiles (user_role_profile_id, platforms, industries, + content_types, avg_follower_growth_pct, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + platforms = COALESCE(EXCLUDED.platforms, social_media_manager_profiles.platforms), + industries = COALESCE(EXCLUDED.industries, social_media_manager_profiles.industries), + content_types = COALESCE(EXCLUDED.content_types, social_media_manager_profiles.content_types), + avg_follower_growth_pct = EXCLUDED.avg_follower_growth_pct, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, platforms, industries, content_types, + avg_follower_growth_pct, starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.platforms) + .bind(&p.industries) + .bind(&p.content_types) + .bind(p.avg_follower_growth_pct) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, SocialMediaManagerProfile>( r#"SELECT id, user_role_profile_id, platforms, industries, content_types, diff --git a/crates/db/src/models/tracecoin_wallet.rs b/crates/db/src/models/tracecoin_wallet.rs new file mode 100644 index 0000000..fc4c816 --- /dev/null +++ b/crates/db/src/models/tracecoin_wallet.rs @@ -0,0 +1,220 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Wallet { + pub id: Uuid, + pub user_id: Uuid, + pub current_balance: i32, + pub reserved: i32, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct LedgerEntry { + pub id: Uuid, + pub wallet_id: Uuid, + pub transaction_type: String, + pub amount: i32, + pub reference_type: String, + pub reference_id: Option, + pub balance_after: Option, + pub remarks: Option, + pub created_at: DateTime, +} + +pub struct TracecoinWalletRepository; + +impl TracecoinWalletRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result { + sqlx::query_as::<_, Wallet>( + "SELECT * FROM tracecoin_wallets WHERE user_id = $1", + ) + .bind(user_id) + .fetch_one(pool) + .await + } + + pub async fn ensure_wallet(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO tracecoin_wallets (user_id, current_balance, reserved) + VALUES ($1, 0, 0) + ON CONFLICT (user_id) DO NOTHING + "#, + ) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn try_reserve_tracecoins( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reference_id: Uuid, + ) -> Result { + let mut tx = pool.begin().await?; + + sqlx::query( + r#" + INSERT INTO tracecoin_wallets (user_id, current_balance, reserved) + VALUES ($1, 0, 0) + ON CONFLICT (user_id) DO NOTHING + "#, + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + + let wallet = sqlx::query_as::<_, Wallet>( + "SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE", + ) + .bind(user_id) + .fetch_one(&mut *tx) + .await?; + + if wallet.current_balance < amount { + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET current_balance = current_balance - $1, reserved = reserved + $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(amount) + .bind(wallet.id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'RESERVE', $2, 'LEAD_REQUEST', $3) + "#, + ) + .bind(wallet.id) + .bind(amount) + .bind(reference_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) + } + + pub async fn try_debit_reserved_tracecoins( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reference_id: Uuid, + ) -> Result { + let mut tx = pool.begin().await?; + + let wallet = sqlx::query_as::<_, Wallet>( + "SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE", + ) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + let Some(wallet) = wallet else { + tx.rollback().await?; + return Ok(false); + }; + + if wallet.reserved < amount { + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET reserved = reserved - $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(amount) + .bind(wallet.id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'DEBIT', $2, 'LEAD_ACCEPTED', $3) + "#, + ) + .bind(wallet.id) + .bind(amount) + .bind(reference_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) + } + + pub async fn try_release_reserved_tracecoins( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reference_id: Uuid, + reason: &str, + ) -> Result { + let mut tx = pool.begin().await?; + + let wallet = sqlx::query_as::<_, Wallet>( + "SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE", + ) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + let Some(wallet) = wallet else { + tx.rollback().await?; + return Ok(false); + }; + + if wallet.reserved < amount { + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET reserved = reserved - $1, current_balance = current_balance + $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(amount) + .bind(wallet.id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'RELEASE', $2, $3, $4) + "#, + ) + .bind(wallet.id) + .bind(amount) + .bind(reason) + .bind(reference_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) + } +} diff --git a/crates/db/src/models/tutor.rs b/crates/db/src/models/tutor.rs index aa5ea58..1220f6f 100644 --- a/crates/db/src/models/tutor.rs +++ b/crates/db/src/models/tutor.rs @@ -32,6 +32,58 @@ pub struct UpsertTutorProfilePayload { pub struct TutorRepository; impl TutorRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, TutorProfile>( + r#"SELECT tp.id, tp.user_role_profile_id, tp.subjects, tp.board_types, tp.qualification, + tp.teaches_online, tp.teaches_offline, tp.experience_years, tp.hourly_rate_inr, + tp.created_at, tp.updated_at + FROM tutor_profiles tp + INNER JOIN user_role_profiles urp ON urp.id = tp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'tutor'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertTutorProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'tutor'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, TutorProfile>( + r#"INSERT INTO tutor_profiles (user_role_profile_id, subjects, board_types, qualification, + teaches_online, teaches_offline, experience_years, hourly_rate_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + subjects = COALESCE(EXCLUDED.subjects, tutor_profiles.subjects), + board_types = COALESCE(EXCLUDED.board_types, tutor_profiles.board_types), + qualification = EXCLUDED.qualification, + teaches_online = EXCLUDED.teaches_online, + teaches_offline = EXCLUDED.teaches_offline, + experience_years = EXCLUDED.experience_years, + hourly_rate_inr = EXCLUDED.hourly_rate_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, subjects, board_types, qualification, + teaches_online, teaches_offline, experience_years, hourly_rate_inr, + created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.subjects) + .bind(&p.board_types) + .bind(&p.qualification) + .bind(p.teaches_online) + .bind(p.teaches_offline) + .bind(p.experience_years) + .bind(p.hourly_rate_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, TutorProfile>( r#"SELECT id, user_role_profile_id, subjects, board_types, qualification, diff --git a/crates/db/src/models/ugc_content_creator.rs b/crates/db/src/models/ugc_content_creator.rs index e03fede..f1e4b1a 100644 --- a/crates/db/src/models/ugc_content_creator.rs +++ b/crates/db/src/models/ugc_content_creator.rs @@ -28,6 +28,53 @@ pub struct UpsertUgcContentCreatorProfilePayload { pub struct UgcContentCreatorRepository; impl UgcContentCreatorRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, UgcContentCreatorProfile>( + r#"SELECT uccp.id, uccp.user_role_profile_id, uccp.niche_tags, uccp.content_formats, uccp.platforms, + uccp.turnaround_days, uccp.starting_price_inr, + uccp.created_at, uccp.updated_at + FROM ugc_content_creator_profiles uccp + INNER JOIN user_role_profiles urp ON urp.id = uccp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'ugc_content_creator'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertUgcContentCreatorProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'ugc_content_creator'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, UgcContentCreatorProfile>( + r#"INSERT INTO ugc_content_creator_profiles (user_role_profile_id, niche_tags, content_formats, + platforms, turnaround_days, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + niche_tags = COALESCE(EXCLUDED.niche_tags, ugc_content_creator_profiles.niche_tags), + content_formats = COALESCE(EXCLUDED.content_formats, ugc_content_creator_profiles.content_formats), + platforms = COALESCE(EXCLUDED.platforms, ugc_content_creator_profiles.platforms), + turnaround_days = EXCLUDED.turnaround_days, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, niche_tags, content_formats, platforms, + turnaround_days, starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.niche_tags) + .bind(&p.content_formats) + .bind(&p.platforms) + .bind(p.turnaround_days) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, UgcContentCreatorProfile>( r#"SELECT id, user_role_profile_id, niche_tags, content_formats, platforms, diff --git a/crates/db/src/models/video_editor.rs b/crates/db/src/models/video_editor.rs index 376affd..dafe78b 100644 --- a/crates/db/src/models/video_editor.rs +++ b/crates/db/src/models/video_editor.rs @@ -26,6 +26,51 @@ pub struct UpsertVideoEditorProfilePayload { pub struct VideoEditorRepository; impl VideoEditorRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, VideoEditorProfile>( + r#"SELECT vep.id, vep.user_role_profile_id, vep.software_skills, vep.style_tags, + vep.turnaround_days, vep.starting_price_inr, + vep.created_at, vep.updated_at + FROM video_editor_profiles vep + INNER JOIN user_role_profiles urp ON urp.id = vep.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'video_editor'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'video_editor'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, VideoEditorProfile>( + r#"INSERT INTO video_editor_profiles (user_role_profile_id, software_skills, style_tags, + turnaround_days, starting_price_inr) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + software_skills = COALESCE(EXCLUDED.software_skills, video_editor_profiles.software_skills), + style_tags = COALESCE(EXCLUDED.style_tags, video_editor_profiles.style_tags), + turnaround_days = EXCLUDED.turnaround_days, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, software_skills, style_tags, turnaround_days, + starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.software_skills) + .bind(&p.style_tags) + .bind(p.turnaround_days) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, VideoEditorProfile>( r#"SELECT id, user_role_profile_id, software_skills, style_tags, turnaround_days,