- 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
585 lines
16 KiB
Rust
585 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 role_key: String,
|
|
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 user_role_profile_id: Uuid,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub display_order: Option<i32>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
|
pub struct Service {
|
|
pub id: Uuid,
|
|
pub user_role_profile_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>,
|
|
}
|
|
|
|
#[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 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)
|
|
.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 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
|
|
"#,
|
|
)
|
|
.bind(profession_key)
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_portfolio(pool: &PgPool, user_role_profile_id: Uuid) -> Result<Vec<PortfolioItem>, sqlx::Error> {
|
|
sqlx::query_as::<_, PortfolioItem>(
|
|
"SELECT * FROM portfolio_items WHERE user_role_profile_id = $1 ORDER BY display_order, created_at DESC",
|
|
)
|
|
.bind(user_role_profile_id)
|
|
.fetch_all(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_services(pool: &PgPool, user_role_profile_id: Uuid) -> Result<Vec<Service>, sqlx::Error> {
|
|
sqlx::query_as::<_, Service>(
|
|
"SELECT * FROM services WHERE user_role_profile_id = $1 AND is_active = true ORDER BY name ASC",
|
|
)
|
|
.bind(user_role_profile_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_user_role_profile_id(
|
|
pool: &PgPool,
|
|
user_role_profile_id: Uuid,
|
|
) -> Result<Option<Uuid>, sqlx::Error> {
|
|
let row = sqlx::query_scalar::<_, Uuid>(
|
|
"SELECT user_id FROM user_role_profiles WHERE id = $1",
|
|
)
|
|
.bind(user_role_profile_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row)
|
|
}
|
|
|
|
pub async fn create_portfolio_item(
|
|
pool: &PgPool,
|
|
user_role_profile_id: Uuid,
|
|
payload: CreatePortfolioItemPayload,
|
|
) -> Result<PortfolioItem, sqlx::Error> {
|
|
sqlx::query_as::<_, PortfolioItem>(
|
|
r#"
|
|
INSERT INTO portfolio_items (user_role_profile_id, title, description, tags)
|
|
VALUES ($1, $2, $3, COALESCE($4::text[], '{}'))
|
|
RETURNING *
|
|
"#,
|
|
)
|
|
.bind(user_role_profile_id)
|
|
.bind(payload.title)
|
|
.bind(payload.description)
|
|
.bind(payload.tags)
|
|
.fetch_one(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn update_portfolio_item(
|
|
pool: &PgPool,
|
|
user_role_profile_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 user_role_profile_id = $5
|
|
RETURNING *
|
|
"#,
|
|
)
|
|
.bind(payload.title)
|
|
.bind(payload.description)
|
|
.bind(payload.tags)
|
|
.bind(id)
|
|
.bind(user_role_profile_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row)
|
|
}
|
|
|
|
pub async fn delete_portfolio_item(
|
|
pool: &PgPool,
|
|
user_role_profile_id: Uuid,
|
|
id: Uuid,
|
|
) -> Result<bool, sqlx::Error> {
|
|
let result = sqlx::query(
|
|
"DELETE FROM portfolio_items WHERE id = $1 AND user_role_profile_id = $2",
|
|
)
|
|
.bind(id)
|
|
.bind(user_role_profile_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(result.rows_affected() > 0)
|
|
}
|
|
|
|
pub async fn create_service(
|
|
pool: &PgPool,
|
|
user_role_profile_id: Uuid,
|
|
payload: CreateServicePayload,
|
|
) -> Result<Service, sqlx::Error> {
|
|
sqlx::query_as::<_, Service>(
|
|
r#"
|
|
INSERT INTO services (user_role_profile_id, name, description, price, duration_minutes)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *
|
|
"#,
|
|
)
|
|
.bind(user_role_profile_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,
|
|
user_role_profile_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 user_role_profile_id = $7
|
|
RETURNING *
|
|
"#,
|
|
)
|
|
.bind(payload.name)
|
|
.bind(payload.description)
|
|
.bind(payload.price)
|
|
.bind(payload.duration_minutes)
|
|
.bind(payload.is_active)
|
|
.bind(id)
|
|
.bind(user_role_profile_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row)
|
|
}
|
|
|
|
pub async fn delete_service(
|
|
pool: &PgPool,
|
|
user_role_profile_id: Uuid,
|
|
id: Uuid,
|
|
) -> Result<bool, sqlx::Error> {
|
|
let result = sqlx::query("DELETE FROM services WHERE id = $1 AND user_role_profile_id = $2")
|
|
.bind(id)
|
|
.bind(user_role_profile_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,
|
|
profession_key: &str,
|
|
) -> Result<Professional, sqlx::Error> {
|
|
let prof = sqlx::query_as::<_, Professional>(
|
|
"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?;
|
|
|
|
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 user_role_profiles
|
|
SET status = 'PENDING_REVIEW', updated_at = NOW()
|
|
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?;
|
|
|
|
Ok(prof)
|
|
}
|
|
}
|