nxtgauge-backend-rust/crates/db/src/models/professional.rs

589 lines
16 KiB
Rust

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Professional {
pub id: Uuid,
pub user_id: Uuid,
pub profession_key: String,
pub display_name: String,
pub location: Option<String>,
pub bio: Option<String>,
pub extra_data_json: Option<serde_json::Value>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
use super::requirement::Requirement;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct PortfolioItem {
pub id: Uuid,
pub professional_id: Uuid,
pub title: String,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub user_id: Option<Uuid>,
pub profession_key: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Service {
pub id: Uuid,
pub professional_id: Uuid,
pub name: String,
pub description: Option<String>,
pub price: i32,
pub duration_minutes: Option<i32>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub user_id: Option<Uuid>,
pub profession_key: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Wallet {
pub id: Uuid,
pub user_id: Uuid,
pub balance: i32,
pub reserved: i32,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct TracecoinLedgerEntry {
pub id: Uuid,
pub wallet_id: Uuid,
pub r#type: String,
pub amount: i32,
pub reason: String,
pub reference_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Invoice {
pub id: Uuid,
pub payment_id: Uuid,
pub user_id: Uuid,
pub invoice_number: String,
pub subtotal: i32,
pub gst_amount: i32,
pub total: i32,
pub status: String,
pub issued_at: DateTime<Utc>,
pub file_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreatePortfolioItemPayload {
pub title: String,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdatePortfolioItemPayload {
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateServicePayload {
pub name: String,
pub description: Option<String>,
pub price: i32,
pub duration_minutes: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateServicePayload {
pub name: Option<String>,
pub description: Option<String>,
pub price: Option<i32>,
pub duration_minutes: Option<i32>,
pub is_active: Option<bool>,
}
pub struct ProfessionalRepository;
impl ProfessionalRepository {
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Professional, sqlx::Error> {
sqlx::query_as::<_, Professional>(
"SELECT * FROM professionals WHERE user_id = $1",
)
.bind(user_id)
.fetch_one(pool)
.await
}
pub async fn get_marketplace(
pool: &PgPool,
profession_key: &str,
page: i64,
limit: i64,
) -> Result<Vec<Requirement>, sqlx::Error> {
let offset = (page - 1) * limit;
sqlx::query_as::<_, Requirement>(
r#"
SELECT * FROM requirements
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
"#,
)
.bind(profession_key)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
}
pub async fn get_portfolio(pool: &PgPool, professional_id: Uuid) -> Result<Vec<PortfolioItem>, sqlx::Error> {
sqlx::query_as::<_, PortfolioItem>(
"SELECT * FROM portfolio_items WHERE professional_id = $1 ORDER BY created_at DESC",
)
.bind(professional_id)
.fetch_all(pool)
.await
}
pub async fn get_services(pool: &PgPool, professional_id: Uuid) -> Result<Vec<Service>, sqlx::Error> {
sqlx::query_as::<_, Service>(
"SELECT * FROM services WHERE professional_id = $1 AND is_active = true ORDER BY name ASC",
)
.bind(professional_id)
.fetch_all(pool)
.await
}
pub async fn get_wallet(pool: &PgPool, user_id: Uuid) -> Result<Wallet, sqlx::Error> {
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, balance, reserved)
VALUES ($1, 0, 0)
ON CONFLICT (user_id) DO NOTHING
"#,
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_user_id_by_professional_id(
pool: &PgPool,
professional_id: Uuid,
) -> Result<Option<Uuid>, sqlx::Error> {
let row = sqlx::query_scalar::<_, Uuid>(
"SELECT user_id FROM professionals WHERE id = $1",
)
.bind(professional_id)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn create_portfolio_item(
pool: &PgPool,
professional_id: Uuid,
payload: CreatePortfolioItemPayload,
) -> Result<PortfolioItem, sqlx::Error> {
sqlx::query_as::<_, PortfolioItem>(
r#"
INSERT INTO portfolio_items (professional_id, title, description, tags)
VALUES ($1, $2, $3, COALESCE($4::text[], '{}'))
RETURNING *
"#,
)
.bind(professional_id)
.bind(payload.title)
.bind(payload.description)
.bind(payload.tags)
.fetch_one(pool)
.await
}
pub async fn update_portfolio_item(
pool: &PgPool,
professional_id: Uuid,
id: Uuid,
payload: UpdatePortfolioItemPayload,
) -> Result<Option<PortfolioItem>, sqlx::Error> {
let row = sqlx::query_as::<_, PortfolioItem>(
r#"
UPDATE portfolio_items
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
tags = COALESCE($3, tags),
updated_at = NOW()
WHERE id = $4 AND professional_id = $5
RETURNING *
"#,
)
.bind(payload.title)
.bind(payload.description)
.bind(payload.tags)
.bind(id)
.bind(professional_id)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn delete_portfolio_item(
pool: &PgPool,
professional_id: Uuid,
id: Uuid,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
"DELETE FROM portfolio_items WHERE id = $1 AND professional_id = $2",
)
.bind(id)
.bind(professional_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn create_service(
pool: &PgPool,
professional_id: Uuid,
payload: CreateServicePayload,
) -> Result<Service, sqlx::Error> {
sqlx::query_as::<_, Service>(
r#"
INSERT INTO services (professional_id, name, description, price, duration_minutes)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
"#,
)
.bind(professional_id)
.bind(payload.name)
.bind(payload.description)
.bind(payload.price)
.bind(payload.duration_minutes)
.fetch_one(pool)
.await
}
pub async fn update_service(
pool: &PgPool,
professional_id: Uuid,
id: Uuid,
payload: UpdateServicePayload,
) -> Result<Option<Service>, sqlx::Error> {
let row = sqlx::query_as::<_, Service>(
r#"
UPDATE services
SET
name = COALESCE($1, name),
description = COALESCE($2, description),
price = COALESCE($3, price),
duration_minutes = COALESCE($4, duration_minutes),
is_active = COALESCE($5, is_active),
updated_at = NOW()
WHERE id = $6 AND professional_id = $7
RETURNING *
"#,
)
.bind(payload.name)
.bind(payload.description)
.bind(payload.price)
.bind(payload.duration_minutes)
.bind(payload.is_active)
.bind(id)
.bind(professional_id)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn delete_service(
pool: &PgPool,
professional_id: Uuid,
id: Uuid,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM services WHERE id = $1 AND professional_id = $2")
.bind(id)
.bind(professional_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn list_wallet_ledger(
pool: &PgPool,
user_id: Uuid,
page: i64,
limit: i64,
) -> Result<Vec<TracecoinLedgerEntry>, sqlx::Error> {
let offset = (page - 1) * limit;
sqlx::query_as::<_, TracecoinLedgerEntry>(
r#"
SELECT l.*
FROM tracecoin_ledger l
INNER JOIN tracecoin_wallets w ON w.id = l.wallet_id
WHERE w.user_id = $1
ORDER BY l.created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
}
pub async fn list_wallet_invoices(
pool: &PgPool,
user_id: Uuid,
page: i64,
limit: i64,
) -> Result<Vec<Invoice>, sqlx::Error> {
let offset = (page - 1) * limit;
sqlx::query_as::<_, Invoice>(
r#"
SELECT *
FROM invoices
WHERE user_id = $1
ORDER BY issued_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
}
pub async fn get_invoice_by_id_for_user(
pool: &PgPool,
user_id: Uuid,
id: Uuid,
) -> Result<Option<Invoice>, sqlx::Error> {
sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoices WHERE id = $1 AND user_id = $2",
)
.bind(id)
.bind(user_id)
.fetch_optional(pool)
.await
}
pub async fn try_reserve_tracecoins(
pool: &PgPool,
user_id: Uuid,
amount: i32,
reference_id: Uuid,
) -> Result<bool, sqlx::Error> {
let mut tx = pool.begin().await?;
sqlx::query(
r#"
INSERT INTO tracecoin_wallets (user_id, 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.balance < amount {
tx.rollback().await?;
return Ok(false);
}
sqlx::query(
r#"
UPDATE tracecoin_wallets
SET balance = 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, type, amount, reason, 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<bool, sqlx::Error> {
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, type, amount, reason, 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<bool, sqlx::Error> {
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, balance = 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, type, amount, reason, 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)
}
pub async fn submit_for_verification(
pool: &PgPool,
user_id: Uuid,
) -> Result<Professional, sqlx::Error> {
let prof = sqlx::query_as::<_, Professional>(
"SELECT * FROM professionals WHERE user_id = $1",
)
.bind(user_id)
.fetch_one(pool)
.await?;
if prof.status == "PENDING_REVIEW" || prof.status == "APPROVED" {
return Err(sqlx::Error::Protocol(format!("Professional profile is already {}", prof.status).into()));
}
let prof = sqlx::query_as::<_, Professional>(
r#"
UPDATE professionals
SET status = 'PENDING_REVIEW', updated_at = NOW()
WHERE user_id = $1
RETURNING *
"#,
)
.bind(user_id)
.fetch_one(pool)
.await?;
Ok(prof)
}
}