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, pub updated_at: DateTime, } 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, pub tags: Option>, pub display_order: Option, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct Service { pub id: Uuid, pub user_role_profile_id: Uuid, pub name: String, pub description: Option, pub price: i32, pub duration_minutes: Option, pub is_active: bool, pub created_at: DateTime, pub updated_at: DateTime, } #[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, } #[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, pub created_at: DateTime, } #[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, pub file_url: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct CreatePortfolioItemPayload { pub title: String, pub description: Option, pub tags: Option>, } #[derive(Debug, Serialize, Deserialize)] pub struct UpdatePortfolioItemPayload { pub title: Option, pub description: Option, pub tags: Option>, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateServicePayload { pub name: String, pub description: Option, pub price: i32, pub duration_minutes: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct UpdateServicePayload { pub name: Option, pub description: Option, pub price: Option, pub duration_minutes: Option, pub is_active: Option, } pub struct ProfessionalRepository; impl ProfessionalRepository { pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result { 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, 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, 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, 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 { 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, 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 { 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, 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 { 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 { 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, 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 { 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, 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, 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, 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 { 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 { 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 { 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 { 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) } }