Compare commits
16 commits
main
...
high-perfo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38bd8a537d | ||
|
|
6defd52016 | ||
|
|
e7c1edac0d | ||
|
|
341322e0ee | ||
|
|
c5b3a9583d | ||
|
|
efd2ec6222 | ||
|
|
024fa05e96 | ||
|
|
0432ddcb51 | ||
|
|
617a75971f | ||
|
|
cdf78635f6 | ||
|
|
f6a23cb99a | ||
|
|
4ee593f407 | ||
|
|
c486f60775 | ||
|
|
4beb380aca | ||
|
|
e8f4262804 | ||
|
|
92072f04bd |
71 changed files with 2691 additions and 678 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3078,6 +3078,7 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"wallet",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -136,10 +136,11 @@ impl From<Application> for AdminApplicationRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_companies(
|
async fn list_companies(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(_q): Query<ListQuery>,
|
Query(_q): Query<ListQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
|
||||||
let companies = sqlx::query_as::<_, CompanyProfile>(
|
let companies = sqlx::query_as::<_, CompanyProfile>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, user_id, company_name, registration_number, industry, website_url,
|
SELECT id, user_id, company_name, registration_number, industry, website_url,
|
||||||
|
|
@ -161,10 +162,11 @@ async fn list_companies(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_company(
|
async fn get_company(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
|
||||||
let company = sqlx::query_as::<_, CompanyProfile>(
|
let company = sqlx::query_as::<_, CompanyProfile>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, user_id, company_name, registration_number, industry, website_url,
|
SELECT id, user_id, company_name, registration_number, industry, website_url,
|
||||||
|
|
@ -187,10 +189,11 @@ async fn get_company(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn approve_company(
|
async fn approve_company(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
|
||||||
sqlx::query("UPDATE company_profiles SET status = 'APPROVED', updated_at = NOW() WHERE id = $1")
|
sqlx::query("UPDATE company_profiles SET status = 'APPROVED', updated_at = NOW() WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
|
|
@ -200,10 +203,11 @@ async fn approve_company(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reject_company(
|
async fn reject_company(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
|
||||||
sqlx::query("UPDATE company_profiles SET status = 'REJECTED', updated_at = NOW() WHERE id = $1")
|
sqlx::query("UPDATE company_profiles SET status = 'REJECTED', updated_at = NOW() WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
|
|
@ -213,10 +217,11 @@ async fn reject_company(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn suspend_company(
|
async fn suspend_company(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
|
||||||
sqlx::query("UPDATE company_profiles SET status = 'SUSPENDED', updated_at = NOW() WHERE id = $1")
|
sqlx::query("UPDATE company_profiles SET status = 'SUSPENDED', updated_at = NOW() WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
|
|
@ -233,11 +238,12 @@ pub struct GrantJobSlotsPayload {
|
||||||
/// Manual top-up for `purchased_job_slots` while the self-serve purchase flow
|
/// Manual top-up for `purchased_job_slots` while the self-serve purchase flow
|
||||||
/// (TraceCoin/PayU) doesn't exist yet. Support-only unblock path.
|
/// (TraceCoin/PayU) doesn't exist yet. Support-only unblock path.
|
||||||
async fn grant_job_slots(
|
async fn grant_job_slots(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<GrantJobSlotsPayload>,
|
Json(payload): Json<GrantJobSlotsPayload>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
|
||||||
if payload.slots <= 0 {
|
if payload.slots <= 0 {
|
||||||
return Err((StatusCode::BAD_REQUEST, "slots must be a positive integer".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "slots must be a positive integer".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -256,10 +262,11 @@ async fn grant_job_slots(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_jobs(
|
async fn list_jobs(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(_q): Query<ListQuery>,
|
Query(_q): Query<ListQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
|
||||||
let jobs = sqlx::query_as::<_, Job>(
|
let jobs = sqlx::query_as::<_, Job>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, company_id, title, category, description, location, job_type,
|
SELECT id, company_id, title, category, description, location, job_type,
|
||||||
|
|
@ -288,6 +295,9 @@ async fn approve_job(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE jobs SET status = 'LIVE', approved_at = NOW(), approved_by = $1 WHERE id = $2 AND status = 'PENDING_APPROVAL'"
|
"UPDATE jobs SET status = 'LIVE', approved_at = NOW(), approved_by = $1 WHERE id = $2 AND status = 'PENDING_APPROVAL'"
|
||||||
)
|
)
|
||||||
|
|
@ -311,6 +321,9 @@ async fn reject_job(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<RejectJobPayload>,
|
Json(payload): Json<RejectJobPayload>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE jobs SET status = 'REJECTED', rejection_reason = $1, approved_by = $2 WHERE id = $3 AND status = 'PENDING_APPROVAL'"
|
"UPDATE jobs SET status = 'REJECTED', rejection_reason = $1, approved_by = $2 WHERE id = $3 AND status = 'PENDING_APPROVAL'"
|
||||||
)
|
)
|
||||||
|
|
@ -330,13 +343,14 @@ async fn reject_job(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_applications(
|
async fn list_applications(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(_q): Query<ListQuery>,
|
Query(_q): Query<ListQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
|
||||||
let applications = sqlx::query_as::<_, Application>(
|
let applications = sqlx::query_as::<_, Application>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, job_id, applicant_user_id, cover_note, status,
|
SELECT id, reference_number, job_id, applicant_user_id, cover_note, status,
|
||||||
applied_at, updated_at
|
applied_at, updated_at
|
||||||
FROM job_applications
|
FROM job_applications
|
||||||
ORDER BY applied_at DESC
|
ORDER BY applied_at DESC
|
||||||
|
|
|
||||||
|
|
@ -199,15 +199,66 @@ async fn create_job(
|
||||||
).into_response();
|
).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- New Quota Logic ---
|
// --- Quota logic ---
|
||||||
let jobs_this_month = match JobRepository::count_by_company_id_this_month(&state.pool, company.id).await {
|
// The free-job-per-month count and the purchased-slot decrement both
|
||||||
Ok(count) => count,
|
// ran as independent, unlocked statements against a company_profiles
|
||||||
|
// row read before either of them -- two concurrent create_job calls for
|
||||||
|
// the same company could each observe "0 jobs this month" (or "slots
|
||||||
|
// remaining > 0") before either commits, letting a company publish 2+
|
||||||
|
// free jobs in a month or drive purchased_job_slots negative. Same
|
||||||
|
// class of bug as the job_applications duplicate-application race.
|
||||||
|
//
|
||||||
|
// Locking company_profiles FOR UPDATE for the whole check+mutate+insert
|
||||||
|
// sequence serializes concurrent create_job calls for one company: the
|
||||||
|
// second call blocks until the first commits, then sees the real,
|
||||||
|
// already-updated counts.
|
||||||
|
let mut tx = match state.pool.begin().await {
|
||||||
|
Ok(tx) => tx,
|
||||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Err(e) = sqlx::query("SELECT id FROM company_profiles WHERE id = $1 FOR UPDATE")
|
||||||
|
.bind(company.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobs_this_month: i64 = match sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM jobs
|
||||||
|
WHERE company_id = $1
|
||||||
|
AND created_at >= date_trunc('month', now())
|
||||||
|
AND status != 'REJECTED'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(company.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(count) => count,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if jobs_this_month >= 1 {
|
if jobs_this_month >= 1 {
|
||||||
// Must use a purchased slot if they've already used their monthly freebie
|
// Must use a purchased slot if they've already used their monthly freebie
|
||||||
if company.purchased_job_slots <= 0 {
|
let deduct_result = sqlx::query(
|
||||||
|
"UPDATE company_profiles SET purchased_job_slots = purchased_job_slots - 1 WHERE id = $1 AND purchased_job_slots > 0",
|
||||||
|
)
|
||||||
|
.bind(company.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match deduct_result {
|
||||||
|
Ok(r) if r.rows_affected() == 1 => {}
|
||||||
|
Ok(_) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
return (
|
return (
|
||||||
StatusCode::PAYMENT_REQUIRED,
|
StatusCode::PAYMENT_REQUIRED,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
|
|
@ -217,20 +268,13 @@ async fn create_job(
|
||||||
}))
|
}))
|
||||||
).into_response();
|
).into_response();
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
// Deduct ONE purchased slot
|
let _ = tx.rollback().await;
|
||||||
let deduct_result = sqlx::query(
|
|
||||||
"UPDATE company_profiles SET purchased_job_slots = purchased_job_slots - 1 WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(company.id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(e) = deduct_result {
|
|
||||||
tracing::error!("Failed to deduct job slot: {}", e);
|
tracing::error!("Failed to deduct job slot: {}", e);
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to deduct quota" }))).into_response();
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to deduct quota" }))).into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// -----------------------
|
// -----------------------
|
||||||
|
|
||||||
let db_payload = DbCreateJobPayload {
|
let db_payload = DbCreateJobPayload {
|
||||||
|
|
@ -246,8 +290,40 @@ async fn create_job(
|
||||||
skills: payload.skills,
|
skills: payload.skills,
|
||||||
};
|
};
|
||||||
|
|
||||||
match JobRepository::create(&state.pool, db_payload).await {
|
let job = match sqlx::query_as::<_, db::models::job::Job>(
|
||||||
Ok(job) => {
|
r#"
|
||||||
|
INSERT INTO jobs (
|
||||||
|
company_id, title, category, description, location,
|
||||||
|
job_type, salary_min, salary_max, experience_years, skills
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(db_payload.company_id)
|
||||||
|
.bind(db_payload.title)
|
||||||
|
.bind(db_payload.category)
|
||||||
|
.bind(db_payload.description)
|
||||||
|
.bind(db_payload.location)
|
||||||
|
.bind(db_payload.job_type.unwrap_or_else(|| "FULL_TIME".to_string()))
|
||||||
|
.bind(db_payload.salary_min)
|
||||||
|
.bind(db_payload.salary_max)
|
||||||
|
.bind(db_payload.experience_years)
|
||||||
|
.bind(db_payload.skills.unwrap_or_default())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(job) => job,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = tx.commit().await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidate company's job list cache
|
// Invalidate company's job list cache
|
||||||
let mut redis = state.redis.clone();
|
let mut redis = state.redis.clone();
|
||||||
let pattern = format!("jobs:company:{}:*", company.id);
|
let pattern = format!("jobs:company:{}:*", company.id);
|
||||||
|
|
@ -256,10 +332,8 @@ async fn create_job(
|
||||||
let _ = redis.del::<_, ()>(keys).await;
|
let _ = redis.del::<_, ()>(keys).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(StatusCode::CREATED, Json(job)).into_response()
|
(StatusCode::CREATED, Json(job)).into_response()
|
||||||
}
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_job(
|
async fn get_job(
|
||||||
|
|
@ -672,10 +746,56 @@ async fn view_contact(
|
||||||
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let free_views = company.free_contact_views;
|
// Claiming the quota (or confirming it's already been spent on this
|
||||||
let purchased_views = company.purchased_contact_views;
|
// application) all happens in one transaction with company_profiles'
|
||||||
|
// row locked FOR UPDATE, so two concurrent view_contact calls for the
|
||||||
|
// same company can't both read the same pre-decrement counters and
|
||||||
|
// both pass the quota check -- the second call blocks until the first
|
||||||
|
// commits, then sees the real, already-decremented balance.
|
||||||
|
//
|
||||||
|
// job_applications.contact_unlocked_at is what makes this idempotent:
|
||||||
|
// without it, re-opening (or simply refreshing) the same application's
|
||||||
|
// contact panel would re-spend the allowance every time, not just under
|
||||||
|
// concurrency -- there was previously no record of "already unlocked
|
||||||
|
// this one" at all.
|
||||||
|
let mut tx = match state.pool.begin().await {
|
||||||
|
Ok(tx) => tx,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to start contact-unlock transaction: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let already_unlocked: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
|
||||||
|
"SELECT contact_unlocked_at FROM job_applications WHERE id = $1 FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
let already_unlocked = matches!(already_unlocked, Some(Some(_)));
|
||||||
|
|
||||||
|
let (used_free, new_free, new_purchased) = if already_unlocked {
|
||||||
|
(false, company.free_contact_views, company.purchased_contact_views)
|
||||||
|
} else {
|
||||||
|
let (free_views, purchased_views): (i32, i32) = match sqlx::query_as(
|
||||||
|
"SELECT free_contact_views, purchased_contact_views FROM company_profiles WHERE id = $1 FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(company.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(row) => row,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
tracing::error!("Failed to lock company_profiles for contact unlock: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if free_views <= 0 && purchased_views <= 0 {
|
if free_views <= 0 && purchased_views <= 0 {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
|
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
|
||||||
"error": "Contact view quota exhausted",
|
"error": "Contact view quota exhausted",
|
||||||
"code": "QUOTA_EXHAUSTED",
|
"code": "QUOTA_EXHAUSTED",
|
||||||
|
|
@ -686,22 +806,53 @@ async fn view_contact(
|
||||||
|
|
||||||
let used_free = free_views > 0;
|
let used_free = free_views > 0;
|
||||||
|
|
||||||
if used_free {
|
let decrement_result = if used_free {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE company_profiles SET free_contact_views = free_contact_views - 1, updated_at = NOW() WHERE id = $1"
|
"UPDATE company_profiles SET free_contact_views = free_contact_views - 1, updated_at = NOW() WHERE id = $1 AND free_contact_views > 0"
|
||||||
)
|
)
|
||||||
.bind(company.id)
|
.bind(company.id)
|
||||||
.execute(&state.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.ok();
|
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE company_profiles SET purchased_contact_views = purchased_contact_views - 1, updated_at = NOW() WHERE id = $1"
|
"UPDATE company_profiles SET purchased_contact_views = purchased_contact_views - 1, updated_at = NOW() WHERE id = $1 AND purchased_contact_views > 0"
|
||||||
)
|
)
|
||||||
.bind(company.id)
|
.bind(company.id)
|
||||||
.execute(&state.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.ok();
|
};
|
||||||
|
|
||||||
|
match decrement_result {
|
||||||
|
Ok(r) if r.rows_affected() == 1 => {}
|
||||||
|
_ => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
|
||||||
|
"error": "Contact view quota exhausted",
|
||||||
|
"code": "QUOTA_EXHAUSTED",
|
||||||
|
"requires_purchase": true,
|
||||||
|
"message": "You have used all your free contact views. Please purchase a contact view package to continue."
|
||||||
|
}))).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = sqlx::query("UPDATE job_applications SET contact_unlocked_at = NOW() WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
tracing::error!("Failed to record contact unlock for application {}: {}", id, e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_free = if used_free { free_views - 1 } else { free_views };
|
||||||
|
let new_purchased = if used_free { purchased_views } else { purchased_views - 1 };
|
||||||
|
(used_free, new_free, new_purchased)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = tx.commit().await {
|
||||||
|
tracing::error!("Failed to commit contact-unlock transaction: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>(
|
let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>(
|
||||||
|
|
@ -717,9 +868,9 @@ async fn view_contact(
|
||||||
|
|
||||||
match contact {
|
match contact {
|
||||||
Ok(Some((name, email, phone))) => {
|
Ok(Some((name, email, phone))) => {
|
||||||
let new_free = if used_free { free_views - 1 } else { free_views };
|
// Only notify the applicant the first time this contact is
|
||||||
let new_purchased = if used_free { purchased_views } else { purchased_views - 1 };
|
// actually unlocked -- not on every idempotent re-fetch.
|
||||||
|
if !already_unlocked {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
||||||
|
|
@ -734,6 +885,7 @@ async fn view_contact(
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
"application_id": id,
|
"application_id": id,
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spawn hourly Accepted-Lead Expiry task (7-day engagement window).
|
||||||
|
let p_lead_req = pool.clone();
|
||||||
|
let m_lead_req = Arc::clone(&mailer);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = time::interval(Duration::from_secs(60 * 60));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
tracing::info!("Running Accepted-Lead Expiry Task...");
|
||||||
|
if let Err(e) = tasks::lead_requests::expire_accepted_leads(&p_lead_req, &m_lead_req).await {
|
||||||
|
tracing::error!("Accepted-Lead Expiry Task Failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Spawn Daily Reminder task
|
// Spawn Daily Reminder task
|
||||||
let p_rem_sys = pool.clone();
|
let p_rem_sys = pool.clone();
|
||||||
let m_rem_sys = Arc::clone(&mailer);
|
let m_rem_sys = Arc::clone(&mailer);
|
||||||
|
|
@ -136,6 +150,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spawn Tracecoin purchase-expiry task (daily -- 3-month validity
|
||||||
|
// window, matches the cadence of the analogous AI-credit expiry task).
|
||||||
|
let p_tc_expiry = pool.clone();
|
||||||
|
let m_tc_expiry = Arc::clone(&mailer);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = time::interval(Duration::from_secs(24 * 60 * 60));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
tracing::info!("Running Tracecoin Purchase Expiry Task...");
|
||||||
|
if let Err(e) = tasks::tracecoin_expiry::expire_purchased_tracecoin_buckets(&p_tc_expiry, &m_tc_expiry).await {
|
||||||
|
tracing::error!("Tracecoin Purchase Expiry Task Failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Keep main thread alive
|
// Keep main thread alive
|
||||||
tokio::signal::ctrl_c().await?;
|
tokio::signal::ctrl_c().await?;
|
||||||
tracing::info!("Shutting down cron engine.");
|
tracing::info!("Shutting down cron engine.");
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ pub async fn expire_stale_jobs(
|
||||||
r#"
|
r#"
|
||||||
UPDATE jobs
|
UPDATE jobs
|
||||||
SET status = 'EXPIRED'
|
SET status = 'EXPIRED'
|
||||||
FROM companies c
|
FROM company_profiles c
|
||||||
JOIN users u ON u.id = c.user_id
|
JOIN users u ON u.id = c.user_id
|
||||||
WHERE jobs.company_id = c.id
|
WHERE jobs.company_id = c.id
|
||||||
AND jobs.status = 'LIVE'
|
AND jobs.status = 'LIVE'
|
||||||
|
|
|
||||||
122
apps/cron/src/tasks/lead_requests.rs
Normal file
122
apps/cron/src/tasks/lead_requests.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
//! Expires ACCEPTED lead_requests 7 days after acceptance -- the checklist's
|
||||||
|
//! "Lead validity: 7 days" rule. Distinct from tasks::leads, which expires
|
||||||
|
//! PENDING (never-accepted) requests after 24h and refunds the reserved
|
||||||
|
//! Tracecoins; this task is about ACCEPTED leads whose engagement window
|
||||||
|
//! has simply run out.
|
||||||
|
//!
|
||||||
|
//! No wallet/ledger involvement here: by the time a lead_request reaches
|
||||||
|
//! ACCEPTED, its Tracecoins were already permanently debited from
|
||||||
|
//! `reserved` (see apps/customers/src/handlers.rs::approve_request ->
|
||||||
|
//! try_debit_reserved_tracecoins) -- the professional already paid for and
|
||||||
|
//! received the accepted lead. Expiry is purely a status/visibility change
|
||||||
|
//! marking the engagement window closed, not a financial reversal.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use email::Mailer;
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const LEAD_VALIDITY_DAYS: i64 = 7;
|
||||||
|
|
||||||
|
pub async fn expire_accepted_leads(
|
||||||
|
pool: &PgPool,
|
||||||
|
mailer: &Mailer,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let cutoff = Utc::now() - chrono::Duration::days(LEAD_VALIDITY_DAYS);
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Record {
|
||||||
|
lead_request_id: Uuid,
|
||||||
|
professional_user_id: Option<Uuid>,
|
||||||
|
professional_email: Option<String>,
|
||||||
|
professional_name: Option<String>,
|
||||||
|
lead_title: Option<String>,
|
||||||
|
customer_user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let records = sqlx::query_as::<_, Record>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
lr.id AS lead_request_id,
|
||||||
|
lr.professional_user_id,
|
||||||
|
u.email AS professional_email,
|
||||||
|
u.full_name AS professional_name,
|
||||||
|
l.title AS lead_title,
|
||||||
|
lr.customer_user_id
|
||||||
|
FROM lead_requests lr
|
||||||
|
LEFT JOIN users u ON u.id = lr.professional_user_id
|
||||||
|
LEFT JOIN leads l ON l.id = lr.lead_id
|
||||||
|
WHERE lr.status = 'ACCEPTED'
|
||||||
|
AND lr.resolved_at IS NOT NULL
|
||||||
|
AND lr.resolved_at < $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(cutoff)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if records.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Found {} accepted lead(s) past the {}-day validity window to expire.", records.len(), LEAD_VALIDITY_DAYS);
|
||||||
|
|
||||||
|
for rec in records {
|
||||||
|
// Guarded on status = 'ACCEPTED' so this is safe to run concurrently
|
||||||
|
// with itself (e.g. an overrunning previous invocation) or to race
|
||||||
|
// a customer/professional action on the same lead_request without
|
||||||
|
// double-processing.
|
||||||
|
let updated = sqlx::query(
|
||||||
|
"UPDATE lead_requests SET status = 'EXPIRED', updated_at = NOW() WHERE id = $1 AND status = 'ACCEPTED'",
|
||||||
|
)
|
||||||
|
.bind(rec.lead_request_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if updated.rows_affected() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = rec.lead_title.unwrap_or_else(|| "your lead".to_string());
|
||||||
|
|
||||||
|
if let Some(professional_user_id) = rec.professional_user_id {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
||||||
|
VALUES ($1, $2, $3, 'LEAD_EXPIRED', $4)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(professional_user_id)
|
||||||
|
.bind("Lead engagement window closed")
|
||||||
|
.bind(format!("Your accepted lead \"{}\" has passed its {}-day engagement window.", title, LEAD_VALIDITY_DAYS))
|
||||||
|
.bind(rec.lead_request_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(customer_user_id) = rec.customer_user_id {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
||||||
|
VALUES ($1, $2, $3, 'LEAD_EXPIRED', $4)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(customer_user_id)
|
||||||
|
.bind("Lead engagement window closed")
|
||||||
|
.bind(format!("The engagement window for \"{}\" has closed.", title))
|
||||||
|
.bind(rec.lead_request_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(email), Some(name)) = (rec.professional_email, rec.professional_name) {
|
||||||
|
if let Err(e) = mailer.send_requirement_expired_email(&email, &name, &title).await {
|
||||||
|
tracing::error!("Failed to send lead-expired email to {}: {:?}", email, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Expired accepted lead_request {} (past {}-day validity window)", rec.lead_request_id, LEAD_VALIDITY_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -61,8 +61,20 @@ pub async fn expire_stale_lead_requests(
|
||||||
}
|
}
|
||||||
|
|
||||||
if rec.tracecoins_reserved > 0 {
|
if rec.tracecoins_reserved > 0 {
|
||||||
|
// Was `current_balance = current_balance + $1` -- that column
|
||||||
|
// doesn't exist on tracecoin_wallets (it's `balance`), so this
|
||||||
|
// UPDATE errored on every run that hit a reserved-coins expiry,
|
||||||
|
// aborting the whole function via `?` before the transaction
|
||||||
|
// committed (the EXPIRED status flip above rolled back with
|
||||||
|
// it). Net effect: leads with reserved coins never actually
|
||||||
|
// expired, and their coins were never refunded -- silently,
|
||||||
|
// forever, every 15 minutes. Also restores `reserved`, not just
|
||||||
|
// `balance` -- the reservation incremented both `reserved` (the
|
||||||
|
// held amount) and left `balance` unchanged, so releasing it
|
||||||
|
// must symmetrically decrement `reserved`, or the coins would
|
||||||
|
// count as both refunded to balance AND still held in reserved.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE tracecoin_wallets SET current_balance = current_balance + $1, updated_at = NOW() WHERE user_id = $2"
|
"UPDATE tracecoin_wallets SET balance = balance + $1, reserved = reserved - $1, updated_at = NOW() WHERE user_id = $2"
|
||||||
)
|
)
|
||||||
.bind(rec.tracecoins_reserved)
|
.bind(rec.tracecoins_reserved)
|
||||||
.bind(rec.user_id)
|
.bind(rec.user_id)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod leads;
|
pub mod leads;
|
||||||
|
pub mod lead_requests;
|
||||||
pub mod requirements;
|
pub mod requirements;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod reminders;
|
pub mod reminders;
|
||||||
pub mod ai_credits;
|
pub mod ai_credits;
|
||||||
|
pub mod tracecoin_expiry;
|
||||||
|
|
|
||||||
124
apps/cron/src/tasks/tracecoin_expiry.rs
Normal file
124
apps/cron/src/tasks/tracecoin_expiry.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
//! Expires purchased-Tracecoin buckets past their 3-month validity window
|
||||||
|
//! (crates/db/src/models/tracecoin_wallet.rs::PURCHASE_VALIDITY_DAYS).
|
||||||
|
//!
|
||||||
|
//! Buckets track individual purchases (see create_purchase_bucket) and get
|
||||||
|
//! drawn down FIFO as the user actually spends (consume_purchase_buckets_fifo,
|
||||||
|
//! called from the real permanent-debit paths). What's left in a bucket by
|
||||||
|
//! the time it expires is coins the user purchased but never spent -- this
|
||||||
|
//! task claws that amount back off the wallet's flat `balance` and writes a
|
||||||
|
//! ledger entry, same as the existing PENDING-lead-request refund pattern.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use email::Mailer;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn expire_purchased_tracecoin_buckets(
|
||||||
|
pool: &PgPool,
|
||||||
|
// Kept in the signature to match every other cron task's shape, even
|
||||||
|
// though it's currently unused -- see the comment near the bottom of
|
||||||
|
// this function for why (no accurate email template exists yet).
|
||||||
|
_mailer: &Mailer,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let user_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT DISTINCT user_id FROM tracecoin_buckets WHERE expires_at < NOW() AND amount_remaining > 0",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if user_ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Found {} user(s) with expired, unspent Tracecoin purchase buckets.", user_ids.len());
|
||||||
|
|
||||||
|
for user_id in user_ids {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
// Lock the wallet for the whole claim -- without this, a concurrent
|
||||||
|
// spend on this same wallet could debit `balance` between our SUM
|
||||||
|
// and our UPDATE, and the two would clobber each other's view of
|
||||||
|
// the correct post-expiry balance.
|
||||||
|
let wallet = sqlx::query_as::<_, (Uuid, i32)>(
|
||||||
|
"SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((wallet_id, balance)) = wallet else {
|
||||||
|
tx.rollback().await?;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let expired_bucket_ids_and_amounts: Vec<(Uuid, i32)> = sqlx::query_as(
|
||||||
|
"SELECT id, amount_remaining FROM tracecoin_buckets WHERE user_id = $1 AND expires_at < NOW() AND amount_remaining > 0 FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total_expired: i32 = expired_bucket_ids_and_amounts.iter().map(|(_, a)| a).sum();
|
||||||
|
if total_expired <= 0 {
|
||||||
|
tx.rollback().await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never drive balance negative even if bookkeeping has drifted
|
||||||
|
// somewhere upstream -- claw back at most what's actually there.
|
||||||
|
let to_debit = total_expired.min(balance);
|
||||||
|
|
||||||
|
if to_debit > 0 {
|
||||||
|
sqlx::query("UPDATE tracecoin_wallets SET balance = balance - $1, updated_at = NOW() WHERE id = $2")
|
||||||
|
.bind(to_debit)
|
||||||
|
.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, 'TRACECOIN_EXPIRY', NULL)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(wallet_id)
|
||||||
|
.bind(to_debit)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (bucket_id, _) in &expired_bucket_ids_and_amounts {
|
||||||
|
sqlx::query("UPDATE tracecoin_buckets SET amount_remaining = 0 WHERE id = $1")
|
||||||
|
.bind(bucket_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
||||||
|
VALUES ($1, $2, $3, 'TRACECOIN_EXPIRY', NULL)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind("Tracecoins expired")
|
||||||
|
.bind(format!(
|
||||||
|
"{} unused purchased Tracecoins passed their 3-month validity window and have expired.",
|
||||||
|
to_debit
|
||||||
|
))
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// No dedicated email template for this exists yet -- the closest
|
||||||
|
// match (send_lead_expired_email, "lead-expired" template) says
|
||||||
|
// "tracecoins_returned", which would actively misstate what just
|
||||||
|
// happened here (coins lost, not refunded). In-app notification
|
||||||
|
// above is accurate; a real templated email is a follow-up, not
|
||||||
|
// something to fake with the wrong template.
|
||||||
|
|
||||||
|
tracing::info!("Expired {} unspent purchased Tracecoins for user {}", to_debit, user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ pub fn router() -> Router<AppState> {
|
||||||
.route("/requirements", get(list_requirements).post(create_requirement))
|
.route("/requirements", get(list_requirements).post(create_requirement))
|
||||||
.route("/requirements/{id}", get(get_requirement).patch(update_requirement))
|
.route("/requirements/{id}", get(get_requirement).patch(update_requirement))
|
||||||
.route("/requirements/{id}/submit", post(submit_requirement))
|
.route("/requirements/{id}/submit", post(submit_requirement))
|
||||||
|
.route("/requirements/{id}/urgent", post(mark_requirement_urgent))
|
||||||
.route("/requests", get(list_requests))
|
.route("/requests", get(list_requests))
|
||||||
.route("/requests/{lead_id}/approve", post(approve_request))
|
.route("/requests/{lead_id}/approve", post(approve_request))
|
||||||
.route("/requests/{lead_id}/reject", post(reject_request))
|
.route("/requests/{lead_id}/reject", post(reject_request))
|
||||||
|
|
@ -218,10 +219,16 @@ async fn create_requirement(
|
||||||
async fn get_requirement(
|
async fn get_requirement(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match RequirementRepository::get_by_id(&state.pool, id).await {
|
match RequirementRepository::get_by_id(&state.pool, id).await {
|
||||||
Ok(Some(req)) => (StatusCode::OK, Json(req)).into_response(),
|
Ok(Some(req)) => {
|
||||||
|
// Ownership check: customer can only view their own requirements
|
||||||
|
if req.created_by_user_id != Some(auth.user_id) {
|
||||||
|
return (StatusCode::NOT_FOUND, "Requirement not found").into_response();
|
||||||
|
}
|
||||||
|
(StatusCode::OK, Json(req)).into_response()
|
||||||
|
}
|
||||||
Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
|
|
@ -261,6 +268,16 @@ async fn submit_requirement(
|
||||||
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Was missing entirely -- every sibling handler on this same resource
|
||||||
|
// (get_requirement, update_requirement, mark_requirement_urgent) checks
|
||||||
|
// ownership; this one didn't, so any authenticated customer could
|
||||||
|
// submit another customer's DRAFT requirement into the approval queue
|
||||||
|
// by id, with the verification case and email attributed to whoever
|
||||||
|
// called this endpoint rather than the actual owner.
|
||||||
|
if req.created_by_user_id != Some(auth.user_id) {
|
||||||
|
return (StatusCode::FORBIDDEN, "You do not own this requirement").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
if req.status != "DRAFT" {
|
if req.status != "DRAFT" {
|
||||||
return (StatusCode::BAD_REQUEST, "Requirement already submitted or closed").into_response();
|
return (StatusCode::BAD_REQUEST, "Requirement already submitted or closed").into_response();
|
||||||
}
|
}
|
||||||
|
|
@ -300,6 +317,119 @@ async fn submit_requirement(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TraceCoins a customer pays to mark their own open requirement urgent.
|
||||||
|
/// Urgent leads sort first in the professional-facing feed, get a raised
|
||||||
|
/// request cap (see MAX_REQUESTS_PER_LEAD_URGENT in profession_shared.rs),
|
||||||
|
/// and trigger an immediate notification to matching professionals. No
|
||||||
|
/// separate expiry -- urgent status lasts exactly as long as the lead
|
||||||
|
/// itself (the existing 7-day expires_at).
|
||||||
|
const URGENT_UPGRADE_COST_TRACECOINS: i32 = 50;
|
||||||
|
|
||||||
|
async fn mark_requirement_urgent(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let req = match RequirementRepository::get_by_id(&state.pool, id).await {
|
||||||
|
Ok(Some(r)) => r,
|
||||||
|
Ok(None) => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if req.created_by_user_id != Some(auth.user_id) {
|
||||||
|
return (StatusCode::FORBIDDEN, "You do not own this requirement").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.status != "OPEN" {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Only an open (approved) requirement can be marked urgent").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.is_urgent {
|
||||||
|
return (StatusCode::OK, Json(req)).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debit before flagging, not after: try_debit_balance is idempotent on
|
||||||
|
// (reference_type, reference_id), keyed to this lead's own id, so a
|
||||||
|
// retry -- or two concurrent requests for the same lead -- can never
|
||||||
|
// double-charge. Charging first also means a failed/insufficient-funds
|
||||||
|
// debit leaves nothing to compensate: the lead is simply never flagged.
|
||||||
|
let debited = match TracecoinWalletRepository::try_debit_balance(
|
||||||
|
&state.pool,
|
||||||
|
auth.user_id,
|
||||||
|
URGENT_UPGRADE_COST_TRACECOINS,
|
||||||
|
"LEAD_URGENT_UPGRADE",
|
||||||
|
req.id,
|
||||||
|
).await {
|
||||||
|
Ok(true) => true,
|
||||||
|
Ok(false) => return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
|
||||||
|
"error": "Insufficient Tracecoins to mark this requirement urgent",
|
||||||
|
"code": "INSUFFICIENT_FUNDS",
|
||||||
|
"cost": URGENT_UPGRADE_COST_TRACECOINS,
|
||||||
|
}))).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("mark_requirement_urgent debit error: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
debug_assert!(debited);
|
||||||
|
|
||||||
|
let updated = match sqlx::query_as::<_, db::models::requirement::Requirement>(
|
||||||
|
r#"
|
||||||
|
UPDATE leads
|
||||||
|
SET is_urgent = true, urgent_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1 AND is_urgent = false
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(req.id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(updated)) => updated,
|
||||||
|
// Already urgent (a concurrent request for the same lead won the
|
||||||
|
// race) -- the debit above was a no-op for it too, so nothing to
|
||||||
|
// undo; just report the current state.
|
||||||
|
Ok(None) => req,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("mark_requirement_urgent failed to flag lead {} after successful debit: {}", req.id, e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Payment succeeded but failed to mark the requirement urgent -- contact support").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Best-effort instant notification to approved professionals in the
|
||||||
|
// same profession + location -- not wrapped in the transaction above,
|
||||||
|
// and never blocks the response.
|
||||||
|
let notify = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
r#"
|
||||||
|
SELECT user_id FROM user_role_profiles
|
||||||
|
WHERE role_key = $1 AND approval_status = 'APPROVED' AND location ILIKE $2
|
||||||
|
LIMIT 500
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&updated.profession_key)
|
||||||
|
.bind(format!("%{}%", updated.location))
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for professional_user_id in notify {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
||||||
|
VALUES ($1, $2, $3, 'LEAD_URGENT', $4)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(professional_user_id)
|
||||||
|
.bind("Urgent lead available")
|
||||||
|
.bind(format!("A new urgent {} lead was just posted in {}.", updated.profession_key, updated.location))
|
||||||
|
.bind(updated.id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, Json(updated)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_requests(
|
async fn list_requests(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
|
|
@ -379,12 +509,21 @@ async fn approve_request(
|
||||||
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
|
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if lead.status != "PENDING" {
|
// update_status_from's WHERE status = 'PENDING' guard makes this
|
||||||
return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response();
|
// atomic against a concurrent approve/reject on the same lead --
|
||||||
|
// previously this was a plain SELECT-then-branch-then-UPDATE with no
|
||||||
|
// re-check, so two racing requests could both pass the `lead.status !=
|
||||||
|
// "PENDING"` check above before either UPDATE committed. See its
|
||||||
|
// doc-comment for the full reasoning.
|
||||||
|
let updated = match LeadRequestRepository::update_status_from(&state.pool, lead.id, "PENDING", "ACCEPTED").await {
|
||||||
|
Ok(Some(updated)) => updated,
|
||||||
|
Ok(None) => return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("approve_request update_status error: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match LeadRequestRepository::update_status(&state.pool, lead.id, "ACCEPTED").await {
|
|
||||||
Ok(updated) => {
|
|
||||||
match TracecoinWalletRepository::try_debit_reserved_tracecoins(
|
match TracecoinWalletRepository::try_debit_reserved_tracecoins(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
lead.professional_user_id.unwrap(),
|
lead.professional_user_id.unwrap(),
|
||||||
|
|
@ -392,22 +531,20 @@ async fn approve_request(
|
||||||
lead.id,
|
lead.id,
|
||||||
).await {
|
).await {
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
Ok(false) => return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response(),
|
Ok(false) | Err(_) => {
|
||||||
Err(e) => {
|
// The debit failed after the status flip already committed --
|
||||||
tracing::error!("approve_request debit error: {}", e);
|
// revert PENDING so the lead isn't left stuck ACCEPTED with its
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response();
|
// Tracecoins never actually debited from reserved.
|
||||||
|
if let Err(e) = LeadRequestRepository::update_status_from(&state.pool, lead.id, "ACCEPTED", "PENDING").await {
|
||||||
|
tracing::error!("approve_request failed to revert status after debit failure for lead {}: {}", lead.id, e);
|
||||||
|
}
|
||||||
|
return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
"lead_request": updated,
|
"lead_request": updated,
|
||||||
}))).into_response()
|
}))).into_response()
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("approve_request update_status error: {}", e);
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reject_request(
|
async fn reject_request(
|
||||||
|
|
@ -441,32 +578,40 @@ async fn reject_request(
|
||||||
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
|
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if lead.status != "PENDING" {
|
let updated = match LeadRequestRepository::update_status_from(&state.pool, lead.id, "PENDING", "REJECTED").await {
|
||||||
return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response();
|
Ok(Some(updated)) => updated,
|
||||||
|
Ok(None) => return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("reject_request update_status error: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match LeadRequestRepository::update_status(&state.pool, lead.id, "REJECTED").await {
|
|
||||||
Ok(updated) => {
|
|
||||||
match TracecoinWalletRepository::try_release_reserved_tracecoins(
|
match TracecoinWalletRepository::try_release_reserved_tracecoins(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
lead.user_role_profile_id.unwrap(),
|
// Was lead.user_role_profile_id -- the wrong id. The wallet is
|
||||||
|
// keyed by user_id, and try_release_reserved_tracecoins looks it up
|
||||||
|
// by user_id, so passing the role-profile id meant this never found
|
||||||
|
// a matching wallet: it silently returned Ok(false), the caller
|
||||||
|
// returned 409 to the customer, but the status flip to REJECTED
|
||||||
|
// above had already committed -- permanently stranding the
|
||||||
|
// professional's reserved Tracecoins with nothing that ever swept
|
||||||
|
// REJECTED leads to release them. approve_request's debit call
|
||||||
|
// (above) already used the correct professional_user_id; this now
|
||||||
|
// matches it.
|
||||||
|
lead.professional_user_id.unwrap(),
|
||||||
lead.tracecoins_reserved,
|
lead.tracecoins_reserved,
|
||||||
lead.id,
|
lead.id,
|
||||||
"LEAD_REJECTED",
|
"LEAD_REJECTED",
|
||||||
).await {
|
).await {
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
Ok(false) => return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response(),
|
Ok(false) | Err(_) => {
|
||||||
Err(e) => {
|
if let Err(e) = LeadRequestRepository::update_status_from(&state.pool, lead.id, "REJECTED", "PENDING").await {
|
||||||
tracing::error!("reject_request release error: {}", e);
|
tracing::error!("reject_request failed to revert status after release failure for lead {}: {}", lead.id, e);
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response();
|
}
|
||||||
|
return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(StatusCode::OK, Json(updated)).into_response()
|
(StatusCode::OK, Json(updated)).into_response()
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("reject_request update_status error: {}", e);
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ impl Services {
|
||||||
|| path.starts_with("/api/config")
|
|| path.starts_with("/api/config")
|
||||||
|| path.starts_with("/api/kb")
|
|| path.starts_with("/api/kb")
|
||||||
|| path.starts_with("/api/packages")
|
|| path.starts_with("/api/packages")
|
||||||
|
|| path.starts_with("/api/coupons")
|
||||||
|| path.starts_with("/api/support")
|
|| path.starts_with("/api/support")
|
||||||
|| path.starts_with("/api/reviews")
|
|| path.starts_with("/api/reviews")
|
||||||
|| path.starts_with("/api/waitlist")
|
|| path.starts_with("/api/waitlist")
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,26 @@ async fn apply_to_job(
|
||||||
return (StatusCode::TOO_MANY_REQUESTS, "Max 50 active applications").into_response();
|
return (StatusCode::TOO_MANY_REQUESTS, "Max 50 active applications").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for duplicate application before attempting INSERT
|
||||||
|
let already_applied = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM job_applications WHERE job_id = $1 AND applicant_user_id = $2)"
|
||||||
|
)
|
||||||
|
.bind(job.id)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if already_applied {
|
||||||
|
return (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"code": "ALREADY_APPLIED",
|
||||||
|
"error": "You have already applied to this job"
|
||||||
|
}))
|
||||||
|
).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
let db_payload = CreateApplicationPayload {
|
let db_payload = CreateApplicationPayload {
|
||||||
job_id: job.id,
|
job_id: job.id,
|
||||||
applicant_user_id: auth.user_id,
|
applicant_user_id: auth.user_id,
|
||||||
|
|
@ -589,9 +609,15 @@ async fn apply_to_job(
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if e.to_string().contains("unique") {
|
if e.to_string().contains("unique") {
|
||||||
(StatusCode::CONFLICT, "Already applied to this job").into_response()
|
(StatusCode::CONFLICT, Json(serde_json::json!({
|
||||||
|
"code": "ALREADY_APPLIED",
|
||||||
|
"error": "You have already applied to this job"
|
||||||
|
}))).into_response()
|
||||||
} else {
|
} else {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"error": "Failed to submit application"
|
||||||
|
}))).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// retrigger-build-marker-2
|
// retrigger-build-marker-3
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Path, Query, State},
|
||||||
http::{HeaderValue, Method, StatusCode},
|
http::{HeaderValue, Method, StatusCode},
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
|
|
@ -38,15 +38,83 @@ pub struct CreateJob {
|
||||||
pub job_type: String,
|
pub job_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_jobs(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Job>>, StatusCode> {
|
// BUG-21 fix: add search/filter params to list_jobs
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct JobQuery {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub location: Option<String>,
|
||||||
|
pub job_type: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub company_id: Option<uuid::Uuid>,
|
||||||
|
pub page: Option<i64>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_jobs(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(params): Query<JobQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
|
let limit = params.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
let q = params.q.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let location_filter = params.location.as_deref().unwrap_or("");
|
||||||
|
let job_type_filter = params.job_type.as_deref().unwrap_or("");
|
||||||
|
let status_filter = params.status.as_deref().unwrap_or("LIVE");
|
||||||
|
|
||||||
let jobs = sqlx::query_as::<_, Job>(
|
let jobs = sqlx::query_as::<_, Job>(
|
||||||
"SELECT id, title, description, location, job_type, status, created_at FROM jobs ORDER BY created_at DESC"
|
r#"
|
||||||
|
SELECT id, title, description, location, job_type, status, created_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE ($1 = 'LIVE' OR status = $1)
|
||||||
|
AND ($2 = '' OR LOWER(title) LIKE '%' || $2 || '%'
|
||||||
|
OR LOWER(description) LIKE '%' || $2 || '%')
|
||||||
|
AND ($3 = '' OR LOWER(location) LIKE '%' || $3 || '%')
|
||||||
|
AND ($4 = '' OR job_type = $4)
|
||||||
|
AND ($5::uuid IS NULL OR company_id = $5)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $6 OFFSET $7
|
||||||
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(status_filter)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(location_filter)
|
||||||
|
.bind(job_type_filter)
|
||||||
|
.bind(params.company_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
Ok(Json(jobs))
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*) FROM jobs
|
||||||
|
WHERE ($1 = 'LIVE' OR status = $1)
|
||||||
|
AND ($2 = '' OR LOWER(title) LIKE '%' || $2 || '%'
|
||||||
|
OR LOWER(description) LIKE '%' || $2 || '%')
|
||||||
|
AND ($3 = '' OR LOWER(location) LIKE '%' || $3 || '%')
|
||||||
|
AND ($4 = '' OR job_type = $4)
|
||||||
|
AND ($5::uuid IS NULL OR company_id = $5)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(status_filter)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(location_filter)
|
||||||
|
.bind(job_type_filter)
|
||||||
|
.bind(params.company_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"jobs": jobs,
|
||||||
|
"pagination": {
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total": total
|
||||||
|
}
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_job(
|
async fn create_job(
|
||||||
|
|
@ -73,7 +141,7 @@ async fn create_job(
|
||||||
|
|
||||||
async fn get_job(
|
async fn get_job(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<Json<Job>, StatusCode> {
|
) -> Result<Json<Job>, StatusCode> {
|
||||||
let job = sqlx::query_as::<_, Job>(
|
let job = sqlx::query_as::<_, Job>(
|
||||||
"SELECT id, title, description, location, job_type, status, created_at FROM jobs WHERE id = $1"
|
"SELECT id, title, description, location, job_type, status, created_at FROM jobs WHERE id = $1"
|
||||||
|
|
@ -157,8 +225,7 @@ async fn main() {
|
||||||
.expect("PORT must be a valid u16");
|
.expect("PORT must be a valid u16");
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
|
||||||
tracing::info!("Jobs service listening on {}", addr);
|
tracing::info!("Jobs service listening on {addr}");
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ anyhow.workspace = true
|
||||||
contracts = { path = "../../crates/contracts" }
|
contracts = { path = "../../crates/contracts" }
|
||||||
db = { path = "../../crates/db" }
|
db = { path = "../../crates/db" }
|
||||||
invoice = { path = "../../crates/invoice" }
|
invoice = { path = "../../crates/invoice" }
|
||||||
|
wallet = { path = "../../crates/wallet" }
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ async fn list_ledger(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
tl.id, tl.wallet_id, tw.user_id, u.email AS user_email,
|
tl.id, tl.wallet_id, tw.user_id, u.email AS user_email,
|
||||||
tl.type AS entry_type, tl.amount, tl.balance_after, tl.reason,
|
tl.transaction_type AS entry_type, tl.amount, tl.balance_after, tl.reference_type AS reason,
|
||||||
tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at
|
tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at
|
||||||
FROM tracecoin_ledger tl
|
FROM tracecoin_ledger tl
|
||||||
JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id
|
JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id
|
||||||
|
|
@ -197,7 +197,7 @@ async fn list_ledger(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
tl.id, tl.wallet_id, tw.user_id, u.email AS user_email,
|
tl.id, tl.wallet_id, tw.user_id, u.email AS user_email,
|
||||||
tl.type AS entry_type, tl.amount, tl.balance_after, tl.reason,
|
tl.transaction_type AS entry_type, tl.amount, tl.balance_after, tl.reference_type AS reason,
|
||||||
tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at
|
tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at
|
||||||
FROM tracecoin_ledger tl
|
FROM tracecoin_ledger tl
|
||||||
JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id
|
JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id
|
||||||
|
|
@ -402,7 +402,7 @@ async fn get_credit_ledger(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
tl.id, tl.wallet_id, tw.user_id, u.email AS user_email,
|
tl.id, tl.wallet_id, tw.user_id, u.email AS user_email,
|
||||||
tl.type AS entry_type, tl.amount, tl.balance_after, tl.reason,
|
tl.transaction_type AS entry_type, tl.amount, tl.balance_after, tl.reference_type AS reason,
|
||||||
tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at
|
tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at
|
||||||
FROM tracecoin_ledger tl
|
FROM tracecoin_ledger tl
|
||||||
JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id
|
JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id
|
||||||
|
|
@ -832,7 +832,7 @@ pub fn admin_router() -> Router<AppState> {
|
||||||
// Tax
|
// Tax
|
||||||
.route("/api/admin/tax", get(list_tax_rules))
|
.route("/api/admin/tax", get(list_tax_rules))
|
||||||
.route("/api/admin/tax", post(create_tax_rule))
|
.route("/api/admin/tax", post(create_tax_rule))
|
||||||
.route("/api/admin/tax/:id", delete(delete_tax_rule))
|
.route("/api/admin/tax/{id}", delete(delete_tax_rule))
|
||||||
// Ledger
|
// Ledger
|
||||||
.route("/api/admin/ledger", get(list_ledger))
|
.route("/api/admin/ledger", get(list_ledger))
|
||||||
// Orders
|
// Orders
|
||||||
|
|
@ -843,11 +843,11 @@ pub fn admin_router() -> Router<AppState> {
|
||||||
.route("/api/admin/credits/adjust", post(adjust_credits))
|
.route("/api/admin/credits/adjust", post(adjust_credits))
|
||||||
.route("/api/admin/credits/reconcile", get(reconcile_credits))
|
.route("/api/admin/credits/reconcile", get(reconcile_credits))
|
||||||
.route("/api/admin/credits/reconcile-report", get(reconcile_credits))
|
.route("/api/admin/credits/reconcile-report", get(reconcile_credits))
|
||||||
.route("/api/admin/credits/reconcile/:order_id", post(reconcile_credits))
|
.route("/api/admin/credits/reconcile/{order_id}", post(reconcile_credits))
|
||||||
// Invoices
|
// Invoices
|
||||||
.route("/api/admin/invoices", get(list_invoices))
|
.route("/api/admin/invoices", get(list_invoices))
|
||||||
.route("/api/admin/invoices/:id", get(get_invoice))
|
.route("/api/admin/invoices/{id}", get(get_invoice))
|
||||||
.route("/api/admin/invoices/:id/html", get(get_invoice_html))
|
.route("/api/admin/invoices/{id}/html", get(get_invoice_html))
|
||||||
.route("/api/admin/invoices/:id/void", axum::routing::post(void_invoice))
|
.route("/api/admin/invoices/{id}/void", axum::routing::post(void_invoice))
|
||||||
.route("/api/admin/invoices/:id/mark-paid", axum::routing::post(mark_invoice_paid))
|
.route("/api/admin/invoices/{id}/mark-paid", axum::routing::post(mark_invoice_paid))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -587,7 +587,7 @@ async fn generate_ai_credit_invoice(pool: &PgPool, order: &AiCreditOrderRow, pay
|
||||||
hsn_sac_code: None,
|
hsn_sac_code: None,
|
||||||
quantity: 1.0,
|
quantity: 1.0,
|
||||||
unit_price_paise: pre_discount_price_paise,
|
unit_price_paise: pre_discount_price_paise,
|
||||||
tax_rate_percent: 18.0,
|
tax_rate_percent: invoice::STANDARD_GST_RATE_PERCENT,
|
||||||
metadata: Some(serde_json::json!({ "package_id": order.package_id, "credits": order.credits })),
|
metadata: Some(serde_json::json!({ "package_id": order.package_id, "credits": order.credits })),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,11 @@ use std::net::SocketAddr;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub mod admin;
|
||||||
pub mod ai_credits;
|
pub mod ai_credits;
|
||||||
pub mod packages;
|
pub mod packages;
|
||||||
pub mod payu;
|
pub mod payu;
|
||||||
|
pub mod reconcile;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
|
|
@ -108,7 +110,7 @@ struct PricingPackageRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, FromRow)]
|
||||||
struct UserContactRow {
|
pub(crate) struct UserContactRow {
|
||||||
email: String,
|
email: String,
|
||||||
full_name: Option<String>,
|
full_name: Option<String>,
|
||||||
phone: Option<String>,
|
phone: Option<String>,
|
||||||
|
|
@ -173,13 +175,25 @@ pub(crate) fn seller_details() -> invoice::SellerDetails {
|
||||||
/// Never blocks the payment response — invoice generation failures are
|
/// Never blocks the payment response — invoice generation failures are
|
||||||
/// logged, not surfaced to the buyer, since the payment itself already
|
/// logged, not surfaced to the buyer, since the payment itself already
|
||||||
/// succeeded and the wallet was already credited by the time this runs.
|
/// succeeded and the wallet was already credited by the time this runs.
|
||||||
|
/// Takes the billed party's details directly rather than a `PaymentRow` +
|
||||||
|
/// `VerifyPaymentRequest`, so it works equally for the live `/verify` call
|
||||||
|
/// (which has PayU's echoed-back checkout payload to draw from) and the
|
||||||
|
/// `reconcile` poll job (which only has the payments/users tables to draw
|
||||||
|
/// from) -- see `finish_successful_payment_side_effects`, the one caller
|
||||||
|
/// both paths now go through.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn generate_purchase_invoice(
|
async fn generate_purchase_invoice(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
payment: &PaymentRow,
|
payment_id: Uuid,
|
||||||
payload: &VerifyPaymentRequest,
|
user_id: Uuid,
|
||||||
|
package_id: Option<Uuid>,
|
||||||
|
amount_inr: i32,
|
||||||
|
firstname: &str,
|
||||||
|
email: &str,
|
||||||
|
phone: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let package_name: Option<String> = sqlx::query_scalar("SELECT name FROM pricing_packages WHERE id = $1")
|
let package_name: Option<String> = sqlx::query_scalar("SELECT name FROM pricing_packages WHERE id = $1")
|
||||||
.bind(payment.package_id)
|
.bind(package_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -195,15 +209,15 @@ async fn generate_purchase_invoice(
|
||||||
// payments.amount_inr is already paise (copied straight from
|
// payments.amount_inr is already paise (copied straight from
|
||||||
// pricing_packages.price_inr, itself paise despite the name — see
|
// pricing_packages.price_inr, itself paise despite the name — see
|
||||||
// payu::paise_to_rupee_string dividing by 100), not rupees.
|
// payu::paise_to_rupee_string dividing by 100), not rupees.
|
||||||
unit_price_paise: payment.amount_inr as i64,
|
unit_price_paise: amount_inr as i64,
|
||||||
tax_rate_percent: 18.0,
|
tax_rate_percent: invoice::STANDARD_GST_RATE_PERCENT,
|
||||||
metadata: payment.package_id.map(|id| serde_json::json!({ "package_id": id })),
|
metadata: package_id.map(|id| serde_json::json!({ "package_id": id })),
|
||||||
};
|
};
|
||||||
|
|
||||||
let customer = invoice::BillingDetails {
|
let customer = invoice::BillingDetails {
|
||||||
legal_name: payload.firstname.clone(),
|
legal_name: firstname.to_string(),
|
||||||
email: Some(payload.email.clone()),
|
email: Some(email.to_string()),
|
||||||
phone: payload.phone.clone(),
|
phone: phone.map(str::to_string),
|
||||||
gstin: None,
|
gstin: None,
|
||||||
pan: None,
|
pan: None,
|
||||||
billing_address: "Billing address not provided".to_string(),
|
billing_address: "Billing address not provided".to_string(),
|
||||||
|
|
@ -211,8 +225,8 @@ async fn generate_purchase_invoice(
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_invoice = invoice::service::NewInvoice {
|
let new_invoice = invoice::service::NewInvoice {
|
||||||
payment_id: payment.id,
|
payment_id,
|
||||||
user_id: payment.user_id,
|
user_id,
|
||||||
currency: "INR".to_string(),
|
currency: "INR".to_string(),
|
||||||
invoice_type: "TRACECOIN_PURCHASE".to_string(),
|
invoice_type: "TRACECOIN_PURCHASE".to_string(),
|
||||||
lines: vec![line],
|
lines: vec![line],
|
||||||
|
|
@ -229,12 +243,78 @@ async fn generate_purchase_invoice(
|
||||||
Ok(inv) => tracing::info!(
|
Ok(inv) => tracing::info!(
|
||||||
"Generated invoice {} for payment {}",
|
"Generated invoice {} for payment {}",
|
||||||
inv.invoice_number,
|
inv.invoice_number,
|
||||||
payment.id
|
payment_id
|
||||||
),
|
),
|
||||||
Err(e) => tracing::error!("Failed to generate invoice for payment {}: {:?}", payment.id, e),
|
Err(e) => tracing::error!("Failed to generate invoice for payment {}: {:?}", payment_id, e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything that must happen once a payment is confirmed SUCCESS and the
|
||||||
|
/// wallet credited, beyond the credit itself: invoice + notification. The
|
||||||
|
/// one place both `verify_payment` (browser redirect came back) and
|
||||||
|
/// `reconcile::reconcile_pending_payments` (browser redirect never came
|
||||||
|
/// back, PayU polled directly) converge, so a purchase looks identical to
|
||||||
|
/// the user regardless of which path noticed it succeeded.
|
||||||
|
///
|
||||||
|
/// Billing contact comes from `users`, not from a client-supplied payload --
|
||||||
|
/// the poll path has no payload to draw from, and using the server's own
|
||||||
|
/// record of the user's name/email for the GST invoice is the more correct
|
||||||
|
/// choice for `/verify` too (a client posting `/verify` could otherwise put
|
||||||
|
/// any name/email it likes on its own invoice).
|
||||||
|
pub(crate) async fn finish_successful_payment_side_effects(
|
||||||
|
pool: &PgPool,
|
||||||
|
finalized: &reconcile::FinalizedPayment,
|
||||||
|
) {
|
||||||
|
let contact = sqlx::query_as::<_, UserContactRow>("SELECT email, full_name, phone FROM users WHERE id = $1")
|
||||||
|
.bind(finalized.user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let (firstname, email, phone) = match contact {
|
||||||
|
Some(c) => (
|
||||||
|
c.full_name
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|n| n.split_whitespace().next())
|
||||||
|
.unwrap_or("Customer")
|
||||||
|
.to_string(),
|
||||||
|
c.email,
|
||||||
|
c.phone,
|
||||||
|
),
|
||||||
|
None => ("Customer".to_string(), String::new(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
generate_purchase_invoice(
|
||||||
|
pool,
|
||||||
|
finalized.id,
|
||||||
|
finalized.user_id,
|
||||||
|
finalized.package_id,
|
||||||
|
finalized.amount_inr,
|
||||||
|
&firstname,
|
||||||
|
&email,
|
||||||
|
phone.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(finalized.user_id)
|
||||||
|
.bind("Tracecoins Purchased Successfully")
|
||||||
|
.bind(format!(
|
||||||
|
"Your {} Tracecoin package has been credited to your wallet.",
|
||||||
|
finalized.tracecoins_credited
|
||||||
|
))
|
||||||
|
.bind("PAYMENT")
|
||||||
|
.bind(finalized.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn build_txnid() -> String {
|
pub(crate) fn build_txnid() -> String {
|
||||||
format!("tc{}", Uuid::new_v4().simple())
|
format!("tc{}", Uuid::new_v4().simple())
|
||||||
.chars()
|
.chars()
|
||||||
|
|
@ -478,65 +558,45 @@ async fn verify_payment(
|
||||||
}
|
}
|
||||||
|
|
||||||
if !payload.status.eq_ignore_ascii_case("success") {
|
if !payload.status.eq_ignore_ascii_case("success") {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Payment was not successful".to_string()));
|
// PayU reported a failure/pending status on a hash-verified payload.
|
||||||
}
|
// Record it so the payment doesn't linger as PENDING (and doesn't
|
||||||
|
// get re-checked forever by the reconciliation poll), same ownership
|
||||||
// The whole claim-and-credit sequence runs in one transaction with the
|
// rules as the success path.
|
||||||
// payments row locked via SELECT ... FOR UPDATE. Without this, two
|
|
||||||
// concurrent calls with the same (replayed) valid PayU success payload
|
|
||||||
// could both observe status = 'PENDING' before either commits its
|
|
||||||
// UPDATE, and both would credit the wallet — a double-credit exploit a
|
|
||||||
// user fully controls, since this endpoint is called directly by the
|
|
||||||
// client after PayU redirects back, not by a server-to-server webhook.
|
|
||||||
let mut tx = state
|
|
||||||
.pool
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
|
||||||
|
|
||||||
let payment = sqlx::query_as::<_, PaymentRow>(
|
let payment = sqlx::query_as::<_, PaymentRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
|
SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
|
||||||
FROM payments
|
FROM payments
|
||||||
WHERE payu_txnid = $1 AND status = 'PENDING'
|
WHERE payu_txnid = $1
|
||||||
FOR UPDATE
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&payload.txnid)
|
.bind(&payload.txnid)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
||||||
let payment = match payment {
|
let Some(payment) = payment else {
|
||||||
Some(payment) => payment,
|
|
||||||
None => {
|
|
||||||
let _ = tx.rollback().await;
|
|
||||||
return Err(error_response(
|
return Err(error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"Payment not found or already processed",
|
"Payment not found or already processed",
|
||||||
));
|
));
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if payment.user_id != auth.user_id {
|
if payment.user_id != auth.user_id {
|
||||||
let _ = tx.rollback().await;
|
|
||||||
return Err(error_response(
|
return Err(error_response(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"Payment does not belong to user",
|
"Payment does not belong to user",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !payload.status.eq_ignore_ascii_case("success") {
|
if payment.status == "PENDING" {
|
||||||
sqlx::query("UPDATE payments SET status = 'FAILED', payu_mihpayid = $1 WHERE id = $2")
|
let _ = sqlx::query(
|
||||||
|
"UPDATE payments SET status = 'FAILED', payu_mihpayid = $1 WHERE id = $2 AND status = 'PENDING'",
|
||||||
|
)
|
||||||
.bind(&payload.mihpayid)
|
.bind(&payload.mihpayid)
|
||||||
.bind(payment.id)
|
.bind(payment.id)
|
||||||
.execute(&mut *tx)
|
.execute(&state.pool)
|
||||||
.await
|
.await;
|
||||||
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
}
|
||||||
|
|
||||||
tx.commit()
|
|
||||||
.await
|
|
||||||
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
|
||||||
|
|
||||||
return Ok(Json(VerifyPaymentResponse {
|
return Ok(Json(VerifyPaymentResponse {
|
||||||
verified: false,
|
verified: false,
|
||||||
|
|
@ -547,79 +607,47 @@ async fn verify_payment(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let tracecoins = payment.tracecoins_credited.unwrap_or(0);
|
// The whole claim-and-credit sequence runs in one transaction with the
|
||||||
|
// payments row locked via SELECT ... FOR UPDATE, gated on status =
|
||||||
sqlx::query(
|
// 'PENDING'. Without this, two concurrent calls with the same
|
||||||
r#"
|
// (replayed) valid PayU success payload could both observe status =
|
||||||
UPDATE payments SET
|
// 'PENDING' before either commits its UPDATE, and both would credit the
|
||||||
status = 'SUCCESS',
|
// wallet — a double-credit exploit a user fully controls, since this
|
||||||
payu_mihpayid = $1,
|
// endpoint is called directly by the client after PayU redirects back,
|
||||||
verified_at = NOW()
|
// not by a server-to-server webhook. The same guard is what makes it
|
||||||
WHERE id = $2
|
// safe for `reconcile::reconcile_pending_payments` to independently
|
||||||
"#,
|
// re-check (and potentially race) this same txnid later.
|
||||||
|
let finalized = match reconcile::finalize_successful_payment(
|
||||||
|
&state.pool,
|
||||||
|
&payload.txnid,
|
||||||
|
&payload.mihpayid,
|
||||||
|
Some(auth.user_id),
|
||||||
)
|
)
|
||||||
.bind(&payload.mihpayid)
|
|
||||||
.bind(payment.id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||||
|
{
|
||||||
|
reconcile::FinalizeOutcome::Credited(finalized) => finalized,
|
||||||
|
reconcile::FinalizeOutcome::NotFound => {
|
||||||
|
return Err(error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Payment not found or already processed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
reconcile::FinalizeOutcome::OwnershipMismatch => {
|
||||||
|
return Err(error_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Payment does not belong to user",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let wallet_id: Uuid = sqlx::query_scalar(
|
let reference_number = finalized.reference_number.clone();
|
||||||
r#"
|
finish_successful_payment_side_effects(&state.pool, &finalized).await;
|
||||||
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
|
|
||||||
VALUES ($1, $2, 0)
|
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
|
||||||
balance = tracecoin_wallets.balance + excluded.balance,
|
|
||||||
updated_at = NOW()
|
|
||||||
RETURNING id
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(payment.user_id)
|
|
||||||
.bind(tracecoins as i64)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
|
|
||||||
VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(wallet_id)
|
|
||||||
.bind(tracecoins as i64)
|
|
||||||
.bind(payment.id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
|
||||||
|
|
||||||
tx.commit()
|
|
||||||
.await
|
|
||||||
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
|
||||||
|
|
||||||
generate_purchase_invoice(&state.pool, &payment, &payload).await;
|
|
||||||
|
|
||||||
let _ = sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(payment.user_id)
|
|
||||||
.bind("Tracecoins Purchased Successfully")
|
|
||||||
.bind(format!(
|
|
||||||
"Your {} Tracecoin package has been credited to your wallet.",
|
|
||||||
tracecoins
|
|
||||||
))
|
|
||||||
.bind("PAYMENT")
|
|
||||||
.bind(payment.id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(Json(VerifyPaymentResponse {
|
Ok(Json(VerifyPaymentResponse {
|
||||||
verified: true,
|
verified: true,
|
||||||
payment_id: payload.mihpayid,
|
payment_id: payload.mihpayid,
|
||||||
reference_number: Some(payment.reference_number.clone()),
|
reference_number: Some(reference_number),
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
message: "Payment verified successfully".to_string(),
|
message: "Payment verified successfully".to_string(),
|
||||||
}))
|
}))
|
||||||
|
|
@ -739,6 +767,24 @@ async fn main() {
|
||||||
payu: payu::PayuConfig::from_env(),
|
payu: payu::PayuConfig::from_env(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Backstop for payments whose browser never redirects back to /verify
|
||||||
|
// (tab closed, network lost, PayU redirect fails) -- see reconcile.rs.
|
||||||
|
// Skipped when PayU isn't configured (e.g. local dev without the env
|
||||||
|
// vars set) since every poll would just fail the same way.
|
||||||
|
if !state.payu.merchant_key.trim().is_empty() && !state.payu.merchant_salt.trim().is_empty() {
|
||||||
|
let pool_reconcile = state.pool.clone();
|
||||||
|
let payu_reconcile = state.payu.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5 * 60));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
reconcile::reconcile_pending_payments(&pool_reconcile, &payu_reconcile).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tracing::warn!("PayU not configured (PAYU_MERCHANT_KEY/PAYU_MERCHANT_SALT) -- payment reconciliation poll disabled");
|
||||||
|
}
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/payments/create-order", post(create_order))
|
.route("/api/payments/create-order", post(create_order))
|
||||||
.route("/api/payments/verify", post(verify_payment))
|
.route("/api/payments/verify", post(verify_payment))
|
||||||
|
|
@ -748,6 +794,13 @@ async fn main() {
|
||||||
.nest("/api/packages", packages::router())
|
.nest("/api/packages", packages::router())
|
||||||
.nest("/api/ai-credits", ai_credits::router())
|
.nest("/api/ai-credits", ai_credits::router())
|
||||||
.nest("/api/admin/ai-credits/packages", ai_credits::admin_router())
|
.nest("/api/admin/ai-credits/packages", ai_credits::admin_router())
|
||||||
|
// admin::admin_router() defines its own full "/api/admin/..." paths
|
||||||
|
// (tax rules, ledger, orders, credits, invoices), so merge rather
|
||||||
|
// than nest -- and see the module's own history: this was written
|
||||||
|
// but never wired up (no `mod admin;`, no dependency on `wallet` in
|
||||||
|
// Cargo.toml), so every one of these routes 404'd in production
|
||||||
|
// despite apps/gateway forwarding traffic to them.
|
||||||
|
.merge(admin::admin_router())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let port: u16 = std::env::var("PORT")
|
let port: u16 = std::env::var("PORT")
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,79 @@ pub fn verify_response_hash(
|
||||||
expected.eq_ignore_ascii_case(received_hash)
|
expected.eq_ignore_ascii_case(received_hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of a `verify_payment` API call against PayU — the server-to-server
|
||||||
|
/// status check used to reconcile orders whose browser never redirected
|
||||||
|
/// back to `/api/payments/verify` (see `reconcile::reconcile_pending_payments`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PayuTransactionStatus {
|
||||||
|
pub status: String,
|
||||||
|
pub mihpayid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls PayU's `verify_payment` API (server-to-server, not the browser
|
||||||
|
/// redirect flow) for a single txnid. Returns `Ok(None)` if PayU has no
|
||||||
|
/// record of the transaction yet (e.g. the user never actually completed
|
||||||
|
/// checkout) rather than treating that as an error worth retrying loudly.
|
||||||
|
///
|
||||||
|
/// Unlike `verify_response_hash`, this call is not itself hash-verified on
|
||||||
|
/// the way back -- the trust boundary here is the outbound HTTPS request
|
||||||
|
/// authenticated by our merchant key/salt, the same way any card-not-present
|
||||||
|
/// merchant-initiated status check works. There is no client-supplied data
|
||||||
|
/// in this path for a forged hash to smuggle through.
|
||||||
|
pub async fn query_transaction_status(
|
||||||
|
config: &PayuConfig,
|
||||||
|
txnid: &str,
|
||||||
|
) -> Result<Option<PayuTransactionStatus>, anyhow::Error> {
|
||||||
|
const COMMAND: &str = "verify_payment";
|
||||||
|
let hash = sha512_hex(&format!(
|
||||||
|
"{}|{}|{}|{}",
|
||||||
|
config.merchant_key, COMMAND, txnid, config.merchant_salt
|
||||||
|
));
|
||||||
|
|
||||||
|
let url = format!("{}/merchant/postservice?form=2", config.base_url);
|
||||||
|
let resp: serde_json::Value = reqwest::Client::new()
|
||||||
|
.post(&url)
|
||||||
|
.form(&[
|
||||||
|
("key", config.merchant_key.as_str()),
|
||||||
|
("command", COMMAND),
|
||||||
|
("var1", txnid),
|
||||||
|
("hash", hash.as_str()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// PayU nests the per-txnid result under `transaction_details.<txnid>`;
|
||||||
|
// some API generations report it under `result.<txnid>` instead. Try
|
||||||
|
// both shapes rather than betting on one.
|
||||||
|
let entry = resp
|
||||||
|
.get("transaction_details")
|
||||||
|
.and_then(|v| v.get(txnid))
|
||||||
|
.or_else(|| resp.get("result").and_then(|v| v.get(txnid)));
|
||||||
|
|
||||||
|
let Some(entry) = entry else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = entry
|
||||||
|
.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let mihpayid = entry
|
||||||
|
.get("mihpayid")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if status.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(PayuTransactionStatus { status, mihpayid }))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn generate_txnid() -> String {
|
pub fn generate_txnid() -> String {
|
||||||
uuid::Uuid::new_v4().to_string().replace('-', "")
|
uuid::Uuid::new_v4().to_string().replace('-', "")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
281
apps/payments/src/reconcile.rs
Normal file
281
apps/payments/src/reconcile.rs
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
//! Shared "claim a PENDING payment and credit the wallet" logic, plus the
|
||||||
|
//! background poll job that backstops it.
|
||||||
|
//!
|
||||||
|
//! `verify_payment` (apps/payments/src/main.rs) is the happy-path caller:
|
||||||
|
//! the browser redirects back from PayU and the client hits `/verify`. But
|
||||||
|
//! that only fires if the browser makes it back at all -- close the tab, lose
|
||||||
|
//! network, or PayU's redirect just fails, and a payment that actually
|
||||||
|
//! succeeded on PayU's side sits at `status = 'PENDING'` forever with
|
||||||
|
//! nothing to notice. `reconcile_pending_payments` is the backstop: poll
|
||||||
|
//! PayU directly for any payment that's been PENDING too long and finish
|
||||||
|
//! the job server-side.
|
||||||
|
//!
|
||||||
|
//! `finalize_successful_payment` is the one place that ever moves a payment
|
||||||
|
//! to SUCCESS and credits the wallet, used by both callers, so there is
|
||||||
|
//! exactly one implementation of the double-credit-proof claim (`SELECT ...
|
||||||
|
//! FOR UPDATE` gated on `status = 'PENDING'`) to reason about.
|
||||||
|
|
||||||
|
use sqlx::{postgres::PgPool, FromRow};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::payu;
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct ClaimRow {
|
||||||
|
id: Uuid,
|
||||||
|
reference_number: String,
|
||||||
|
user_id: Uuid,
|
||||||
|
package_id: Option<Uuid>,
|
||||||
|
tracecoins_credited: Option<i32>,
|
||||||
|
amount_inr: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A payment that was just moved PENDING -> SUCCESS and credited, with
|
||||||
|
/// enough data for the caller to generate an invoice / notify the user.
|
||||||
|
pub struct FinalizedPayment {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub reference_number: String,
|
||||||
|
pub tracecoins_credited: i32,
|
||||||
|
pub amount_inr: i32,
|
||||||
|
pub package_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum FinalizeOutcome {
|
||||||
|
Credited(FinalizedPayment),
|
||||||
|
/// No PENDING payment exists for this txnid -- already handled by
|
||||||
|
/// another caller (verify_payment vs. the poll job racing, or a
|
||||||
|
/// duplicate PayU status callback), or the txnid is unknown to us.
|
||||||
|
NotFound,
|
||||||
|
/// A PENDING payment exists but belongs to a different user than the
|
||||||
|
/// caller claims. Only possible from `verify_payment`'s auth'd path;
|
||||||
|
/// the poll job never passes an `expected_user_id`.
|
||||||
|
OwnershipMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claims a PENDING payment by txnid and credits the wallet, atomically.
|
||||||
|
///
|
||||||
|
/// The whole claim-and-credit sequence runs in one transaction with the
|
||||||
|
/// payments row locked via `SELECT ... FOR UPDATE`, gated on `status =
|
||||||
|
/// 'PENDING'`. That's what makes this safe to call twice for the same
|
||||||
|
/// txnid (verify_payment fires, then the poll job independently notices the
|
||||||
|
/// same payment before or after): whichever call reaches the row first
|
||||||
|
/// moves it out of PENDING inside its own transaction, so the second call
|
||||||
|
/// simply finds nothing to claim and returns `NotFound` -- never a second
|
||||||
|
/// credit.
|
||||||
|
///
|
||||||
|
/// `expected_user_id`, when set, must match the payment's owner or the
|
||||||
|
/// claim is refused before any credit happens (used by `verify_payment`,
|
||||||
|
/// which is called by a client-supplied JWT; the poll job passes `None`
|
||||||
|
/// since it isn't acting on behalf of any particular request).
|
||||||
|
pub async fn finalize_successful_payment(
|
||||||
|
pool: &PgPool,
|
||||||
|
txnid: &str,
|
||||||
|
mihpayid: &str,
|
||||||
|
expected_user_id: Option<Uuid>,
|
||||||
|
) -> Result<FinalizeOutcome, sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let payment = sqlx::query_as::<_, ClaimRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr
|
||||||
|
FROM payments
|
||||||
|
WHERE payu_txnid = $1 AND status = 'PENDING'
|
||||||
|
FOR UPDATE
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(txnid)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let payment = match payment {
|
||||||
|
Some(payment) => payment,
|
||||||
|
None => {
|
||||||
|
tx.rollback().await.ok();
|
||||||
|
return Ok(FinalizeOutcome::NotFound);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(expected) = expected_user_id {
|
||||||
|
if payment.user_id != expected {
|
||||||
|
tx.rollback().await.ok();
|
||||||
|
return Ok(FinalizeOutcome::OwnershipMismatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracecoins = payment.tracecoins_credited.unwrap_or(0);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE payments SET
|
||||||
|
status = 'SUCCESS',
|
||||||
|
payu_mihpayid = $1,
|
||||||
|
verified_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(mihpayid)
|
||||||
|
.bind(payment.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let wallet_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
|
||||||
|
VALUES ($1, $2, 0)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
balance = tracecoin_wallets.balance + excluded.balance,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(payment.user_id)
|
||||||
|
.bind(tracecoins as i64)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
|
||||||
|
VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(wallet_id)
|
||||||
|
.bind(tracecoins as i64)
|
||||||
|
.bind(payment.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// "Purchased credits valid for 3 months" -- this PayU credit is the
|
||||||
|
// only real Tracecoin *purchase* path in the codebase, so it's the
|
||||||
|
// only place that should create an expiring bucket. reference_id =
|
||||||
|
// payment.id makes this idempotent the same way the ledger insert
|
||||||
|
// above already is (ON CONFLICT DO NOTHING on the bucket's own unique
|
||||||
|
// index), so a retried finalize for the same payment can't create two.
|
||||||
|
db::models::tracecoin_wallet::TracecoinWalletRepository::create_purchase_bucket(
|
||||||
|
&mut tx,
|
||||||
|
payment.user_id,
|
||||||
|
tracecoins,
|
||||||
|
"PAYU_PURCHASE",
|
||||||
|
payment.id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(FinalizeOutcome::Credited(FinalizedPayment {
|
||||||
|
id: payment.id,
|
||||||
|
user_id: payment.user_id,
|
||||||
|
reference_number: payment.reference_number,
|
||||||
|
tracecoins_credited: tracecoins,
|
||||||
|
amount_inr: payment.amount_inr,
|
||||||
|
package_id: payment.package_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct StalePendingRow {
|
||||||
|
id: Uuid,
|
||||||
|
payu_txnid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a payment can sit PENDING before we start asking PayU about it.
|
||||||
|
/// Short enough that legitimate slow redirects (a few minutes on a bad
|
||||||
|
/// connection) aren't chased, long enough that we're not hammering PayU for
|
||||||
|
/// orders still mid-checkout.
|
||||||
|
const RECONCILE_AFTER_MINUTES: i64 = 15;
|
||||||
|
|
||||||
|
/// Payments older than this are almost certainly abandoned carts (user
|
||||||
|
/// never paid at all), not missed callbacks -- stop polling PayU about them
|
||||||
|
/// so the poll batch stays dominated by payments actually worth chasing.
|
||||||
|
const GIVE_UP_AFTER_DAYS: i64 = 7;
|
||||||
|
|
||||||
|
const BATCH_SIZE: i64 = 25;
|
||||||
|
|
||||||
|
/// One pass of the reconciliation poll: find payments stuck PENDING past
|
||||||
|
/// `RECONCILE_AFTER_MINUTES`, ask PayU directly what happened to each, and
|
||||||
|
/// finalize (credit) or fail them accordingly. Meant to be called on a
|
||||||
|
/// timer from `main`, the same way the `cron` app's tasks are.
|
||||||
|
pub async fn reconcile_pending_payments(pool: &PgPool, payu: &payu::PayuConfig) {
|
||||||
|
let stale = match sqlx::query_as::<_, StalePendingRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, payu_txnid
|
||||||
|
FROM payments
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
AND created_at < NOW() - ($1 || ' minutes')::interval
|
||||||
|
AND created_at > NOW() - ($2 || ' days')::interval
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(RECONCILE_AFTER_MINUTES.to_string())
|
||||||
|
.bind(GIVE_UP_AFTER_DAYS.to_string())
|
||||||
|
.bind(BATCH_SIZE)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Payment reconciliation: failed to query stale PENDING payments: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if stale.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Payment reconciliation: checking {} stale PENDING payment(s) against PayU", stale.len());
|
||||||
|
|
||||||
|
for row in stale {
|
||||||
|
let Some(txnid) = row.payu_txnid else {
|
||||||
|
// No txnid was ever recorded -- nothing to ask PayU about.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match payu::query_transaction_status(payu, &txnid).await {
|
||||||
|
Ok(Some(status)) if status.status.eq_ignore_ascii_case("success") => {
|
||||||
|
match finalize_successful_payment(pool, &txnid, &status.mihpayid, None).await {
|
||||||
|
Ok(FinalizeOutcome::Credited(finalized)) => {
|
||||||
|
tracing::info!(
|
||||||
|
"Payment reconciliation: credited abandoned-redirect payment {} ({} tracecoins)",
|
||||||
|
finalized.id,
|
||||||
|
finalized.tracecoins_credited
|
||||||
|
);
|
||||||
|
crate::finish_successful_payment_side_effects(pool, &finalized).await;
|
||||||
|
}
|
||||||
|
Ok(FinalizeOutcome::NotFound) => {
|
||||||
|
// Someone else (verify_payment, or a previous poll
|
||||||
|
// that raced this one) already claimed it -- fine.
|
||||||
|
}
|
||||||
|
Ok(FinalizeOutcome::OwnershipMismatch) => {
|
||||||
|
// Unreachable: the poll job never passes
|
||||||
|
// expected_user_id, so this arm can't occur here.
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Payment reconciliation: failed to finalize payment {}: {e}", row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(status)) if status.status.eq_ignore_ascii_case("failure") => {
|
||||||
|
if let Err(e) = sqlx::query(
|
||||||
|
"UPDATE payments SET status = 'FAILED', payu_mihpayid = $1 WHERE id = $2 AND status = 'PENDING'",
|
||||||
|
)
|
||||||
|
.bind(&status.mihpayid)
|
||||||
|
.bind(row.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("Payment reconciliation: failed to mark payment {} FAILED: {e}", row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
// Still pending at PayU (or PayU has no record yet) --
|
||||||
|
// leave it alone, we'll check again next pass.
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Payment reconciliation: PayU status query failed for txnid {txnid}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -49,11 +49,13 @@ struct PaginatedResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_activity_logs(
|
async fn list_activity_logs(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<ListQuery>,
|
Query(params): Query<ListQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Ensure admin permission (require_admin will be applied by router if nested under /api/admin)
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let page = params.page.unwrap_or(1).max(1);
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
let limit = params.limit.unwrap_or(50).clamp(1, 100);
|
let limit = params.limit.unwrap_or(50).clamp(1, 100);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use axum::{
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
@ -40,10 +40,11 @@ pub struct AdminUserRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_users(
|
async fn list_users(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<ListQuery>,
|
Query(q): Query<ListQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
|
||||||
let search = q.q.as_deref().unwrap_or_default().to_lowercase();
|
let search = q.q.as_deref().unwrap_or_default().to_lowercase();
|
||||||
let role_filter = q.role.as_deref().unwrap_or_default().to_uppercase();
|
let role_filter = q.role.as_deref().unwrap_or_default().to_uppercase();
|
||||||
|
|
||||||
|
|
@ -104,10 +105,11 @@ async fn list_users(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_customers(
|
async fn list_customers(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<ListQuery>,
|
Query(q): Query<ListQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
|
||||||
let search = q.q.unwrap_or_default().to_lowercase();
|
let search = q.q.unwrap_or_default().to_lowercase();
|
||||||
|
|
||||||
let sql = r#"
|
let sql = r#"
|
||||||
|
|
@ -132,10 +134,11 @@ async fn list_customers(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_candidates(
|
async fn list_candidates(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<ListQuery>,
|
Query(q): Query<ListQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
|
||||||
let search = q.q.unwrap_or_default().to_lowercase();
|
let search = q.q.unwrap_or_default().to_lowercase();
|
||||||
|
|
||||||
let sql = r#"
|
let sql = r#"
|
||||||
|
|
@ -165,11 +168,12 @@ pub struct StatusPayload {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_user_status(
|
async fn update_user_status(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<StatusPayload>,
|
Json(payload): Json<StatusPayload>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
|
||||||
sqlx::query("UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2")
|
sqlx::query("UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2")
|
||||||
.bind(&payload.status)
|
.bind(&payload.status)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|
|
||||||
|
|
@ -583,9 +583,12 @@ pub struct AnalyticsQuery {
|
||||||
|
|
||||||
async fn analytics_overview(
|
async fn analytics_overview(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
axum::extract::Query(q): axum::extract::Query<AnalyticsQuery>,
|
axum::extract::Query(q): axum::extract::Query<AnalyticsQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let days = q.days.unwrap_or(7).clamp(1, 90);
|
let days = q.days.unwrap_or(7).clamp(1, 90);
|
||||||
|
|
||||||
let totals_row = sqlx::query(
|
let totals_row = sqlx::query(
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,22 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>
|
||||||
let normalized_intent = intent.map(normalize_role_key).unwrap_or_default();
|
let normalized_intent = intent.map(normalize_role_key).unwrap_or_default();
|
||||||
let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty());
|
let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty());
|
||||||
|
|
||||||
if normalized_intent.is_empty() {
|
// BUG-26 fix: when role_key is sent directly (aliased → profession field)
|
||||||
|
// and intent is not provided, treat the profession as the intent.
|
||||||
|
// e.g. register with role_key=PHOTOGRAPHER → profession=PHOTOGRAPHER, intent=None
|
||||||
|
let effective_intent = if normalized_intent.is_empty() {
|
||||||
|
normalized_profession.clone().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
normalized_intent.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if effective_intent.is_empty() {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-alias for clarity in the checks below
|
||||||
|
let normalized_intent = effective_intent;
|
||||||
|
|
||||||
if normalized_intent.contains("COMPANY") {
|
if normalized_intent.contains("COMPANY") {
|
||||||
return vec!["COMPANY".to_string()];
|
return vec!["COMPANY".to_string()];
|
||||||
}
|
}
|
||||||
|
|
@ -172,6 +184,26 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BUG-26 fix: if the intent IS a known professional role key directly
|
||||||
|
// (e.g., PHOTOGRAPHER, MAKEUP_ARTIST, TUTOR) — return it as-is.
|
||||||
|
// These are sent by some flows that pass the role_key directly instead
|
||||||
|
// of passing "PROFESSIONAL" + profession.
|
||||||
|
const KNOWN_PROFESSIONAL_ROLES: &[&str] = &[
|
||||||
|
"PHOTOGRAPHER",
|
||||||
|
"MAKEUP_ARTIST",
|
||||||
|
"TUTOR",
|
||||||
|
"DEVELOPER",
|
||||||
|
"VIDEO_EDITOR",
|
||||||
|
"GRAPHIC_DESIGNER",
|
||||||
|
"SOCIAL_MEDIA_MANAGER",
|
||||||
|
"FITNESS_TRAINER",
|
||||||
|
"CATERING_SERVICES",
|
||||||
|
"UGC_CONTENT_CREATOR",
|
||||||
|
];
|
||||||
|
if KNOWN_PROFESSIONAL_ROLES.contains(&normalized_intent.as_str()) {
|
||||||
|
return vec![normalized_intent];
|
||||||
|
}
|
||||||
|
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,6 +465,24 @@ async fn register(
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(role_key = %role_key, "Role not found in database");
|
tracing::warn!(role_key = %role_key, "Role not found in database");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BUG-26 fix: auto-create user_role_profiles row for every role at registration.
|
||||||
|
// All professional-type services and job_seeker/company/customer UX rely on
|
||||||
|
// this row existing. Without it, the first PATCH /profile/me returns 500.
|
||||||
|
let display_name = role_display_name_from_code(&role_key);
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO user_role_profiles (user_id, role_key, display_name, status, verification_status, approval_status)
|
||||||
|
VALUES ($1, $2, $3, 'ACTIVE', 'PENDING', 'PENDING')
|
||||||
|
ON CONFLICT (user_id, role_key) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(&role_key)
|
||||||
|
.bind(&display_name)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
tracing::info!(role_key = %role_key, "Auto-created user_role_profiles row");
|
||||||
}
|
}
|
||||||
|
|
||||||
// For demo accounts: auto-verify email and skip OTP
|
// For demo accounts: auto-verify email and skip OTP
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ pub fn dashboard_router() -> Router<AppState> {
|
||||||
|
|
||||||
pub fn runtime_router() -> Router<AppState> {
|
pub fn runtime_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(get_my_runtime_config).post(create_runtime_config))
|
.route("/", get(get_my_runtime_config))
|
||||||
.route("/{role_id}", get(get_active_runtime_config))
|
.route("/{role_id}", get(get_active_runtime_config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use axum::{
|
||||||
routing::{get, patch, post},
|
routing::{get, patch, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -19,6 +19,12 @@ pub fn coupons_router() -> Router<AppState> {
|
||||||
.route("/validate", post(validate_coupon))
|
.route("/validate", post(validate_coupon))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User-facing coupon validation router — mounted at /api/coupons
|
||||||
|
pub fn user_coupons_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/validate", post(user_validate_coupon))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn discounts_router() -> Router<AppState> {
|
pub fn discounts_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_discounts).post(create_discount))
|
.route("/", get(list_discounts).post(create_discount))
|
||||||
|
|
@ -178,9 +184,12 @@ struct ExistingDiscountRow {
|
||||||
// ── Coupon handlers ───────────────────────────────────────────────────────────
|
// ── Coupon handlers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn list_coupons(
|
async fn list_coupons(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let rows = sqlx::query_as::<_, CouponRow>(
|
let rows = sqlx::query_as::<_, CouponRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, code, title, discount_type, discount_value, min_order_amount,
|
SELECT id, code, title, discount_type, discount_value, min_order_amount,
|
||||||
|
|
@ -221,10 +230,13 @@ async fn list_coupons(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_coupon(
|
async fn create_coupon(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<CreateCouponBody>,
|
Json(body): Json<CreateCouponBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
|
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
|
||||||
let value = body.value.unwrap_or(0);
|
let value = body.value.unwrap_or(0);
|
||||||
let min_order = body.min_order_amount.unwrap_or(0);
|
let min_order = body.min_order_amount.unwrap_or(0);
|
||||||
|
|
@ -282,11 +294,14 @@ async fn create_coupon(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_coupon(
|
async fn update_coupon(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<PatchCouponBody>,
|
Json(body): Json<PatchCouponBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let existing = sqlx::query_as::<_, ExistingCouponRow>(
|
let existing = sqlx::query_as::<_, ExistingCouponRow>(
|
||||||
"SELECT code, title, discount_type, discount_value, min_order_amount, max_uses, role_keys, is_active FROM coupons WHERE id = $1",
|
"SELECT code, title, discount_type, discount_value, min_order_amount, max_uses, role_keys, is_active FROM coupons WHERE id = $1",
|
||||||
)
|
)
|
||||||
|
|
@ -342,10 +357,13 @@ async fn update_coupon(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_coupon(
|
async fn delete_coupon(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let result = sqlx::query("DELETE FROM coupons WHERE id = $1")
|
let result = sqlx::query("DELETE FROM coupons WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
|
|
@ -366,9 +384,12 @@ async fn delete_coupon(
|
||||||
// ── Discount handlers ─────────────────────────────────────────────────────────
|
// ── Discount handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn list_discounts(
|
async fn list_discounts(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let rows = sqlx::query_as::<_, DiscountRow>(
|
let rows = sqlx::query_as::<_, DiscountRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, title, scope, role_key, package_id, discount_type, discount_value, is_active
|
SELECT id, title, scope, role_key, package_id, discount_type, discount_value, is_active
|
||||||
|
|
@ -404,10 +425,13 @@ async fn list_discounts(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_discount(
|
async fn create_discount(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<CreateDiscountBody>,
|
Json(body): Json<CreateDiscountBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let scope = body.scope.unwrap_or_else(|| "ROLE".to_string());
|
let scope = body.scope.unwrap_or_else(|| "ROLE".to_string());
|
||||||
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
|
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
|
||||||
let value = body.value.unwrap_or(0);
|
let value = body.value.unwrap_or(0);
|
||||||
|
|
@ -450,11 +474,14 @@ async fn create_discount(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_discount(
|
async fn update_discount(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<PatchDiscountBody>,
|
Json(body): Json<PatchDiscountBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let existing = sqlx::query_as::<_, ExistingDiscountRow>(
|
let existing = sqlx::query_as::<_, ExistingDiscountRow>(
|
||||||
"SELECT title, scope, role_key, package_id, discount_type, discount_value, is_active FROM discounts WHERE id = $1",
|
"SELECT title, scope, role_key, package_id, discount_type, discount_value, is_active FROM discounts WHERE id = $1",
|
||||||
)
|
)
|
||||||
|
|
@ -521,10 +548,11 @@ struct ValidateCouponResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn validate_coupon(
|
async fn validate_coupon(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<ValidateCouponPayload>,
|
Json(payload): Json<ValidateCouponPayload>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
|
||||||
let code = payload.coupon_code.trim().to_uppercase();
|
let code = payload.coupon_code.trim().to_uppercase();
|
||||||
|
|
||||||
// Fetch coupon
|
// Fetch coupon
|
||||||
|
|
@ -610,7 +638,7 @@ async fn validate_coupon(
|
||||||
discount_type: None,
|
discount_type: None,
|
||||||
discount_value: None,
|
discount_value: None,
|
||||||
final_price_inr: payload.package_price_inr,
|
final_price_inr: payload.package_price_inr,
|
||||||
message: format!("Minimum order amount ₹{} required", coupon.min_order_amount / 100),
|
message: format!("Minimum order amount ₹{} required", coupon.min_order_amount),
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -659,3 +687,103 @@ async fn validate_coupon(
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User-facing coupon validation (BUG-44 fix) ────────────────────────────────
|
||||||
|
//
|
||||||
|
// POST /api/coupons/validate — same logic as admin validate_coupon but without
|
||||||
|
// require_admin(). Allows authenticated users to validate a coupon code at
|
||||||
|
// checkout before initiating payment.
|
||||||
|
|
||||||
|
async fn user_validate_coupon(
|
||||||
|
_auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ValidateCouponPayload>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
// No require_admin — any authenticated user may validate a coupon
|
||||||
|
let code = payload.coupon_code.trim().to_uppercase();
|
||||||
|
|
||||||
|
let coupon = sqlx::query_as::<_, ValidateCouponRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, code, discount_type, discount_value, min_order_amount,
|
||||||
|
max_uses, role_keys, valid_until, is_active
|
||||||
|
FROM coupons
|
||||||
|
WHERE code = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&code)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
||||||
|
let coupon = match coupon {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Ok((StatusCode::OK, Json(ValidateCouponResponse {
|
||||||
|
valid: false, discount_type: None, discount_value: None,
|
||||||
|
final_price_inr: payload.package_price_inr, message: "Coupon not found".to_string(),
|
||||||
|
}))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !coupon.is_active {
|
||||||
|
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
|
||||||
|
valid: false, discount_type: None, discount_value: None,
|
||||||
|
final_price_inr: payload.package_price_inr, message: "Coupon is inactive".to_string(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(valid_until) = coupon.valid_until {
|
||||||
|
if valid_until < chrono::Utc::now() {
|
||||||
|
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
|
||||||
|
valid: false, discount_type: None, discount_value: None,
|
||||||
|
final_price_inr: payload.package_price_inr, message: "Coupon has expired".to_string(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !coupon.role_keys.is_empty() && !coupon.role_keys.iter().any(|r| r.eq_ignore_ascii_case(&payload.role_key)) {
|
||||||
|
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
|
||||||
|
valid: false, discount_type: None, discount_value: None,
|
||||||
|
final_price_inr: payload.package_price_inr, message: "Coupon not valid for your role".to_string(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.package_price_inr < coupon.min_order_amount {
|
||||||
|
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
|
||||||
|
valid: false, discount_type: None, discount_value: None,
|
||||||
|
final_price_inr: payload.package_price_inr,
|
||||||
|
message: format!("Minimum order amount ₹{} required", coupon.min_order_amount),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(max_uses) = coupon.max_uses {
|
||||||
|
let count: i64 = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM coupon_uses WHERE coupon_id = $1",
|
||||||
|
)
|
||||||
|
.bind(coupon.id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
if count >= max_uses as i64 {
|
||||||
|
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
|
||||||
|
valid: false, discount_type: None, discount_value: None,
|
||||||
|
final_price_inr: payload.package_price_inr, message: "Coupon usage limit reached".to_string(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_price = match coupon.discount_type.as_str() {
|
||||||
|
"PERCENT" => {
|
||||||
|
let discount = ((payload.package_price_inr as f64) * (coupon.discount_value as f64) / 100.0).round() as i32;
|
||||||
|
(payload.package_price_inr - discount).max(0)
|
||||||
|
}
|
||||||
|
"FIXED" => (payload.package_price_inr - coupon.discount_value).max(0),
|
||||||
|
_ => payload.package_price_inr,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, Json(ValidateCouponResponse {
|
||||||
|
valid: true,
|
||||||
|
discount_type: Some(coupon.discount_type),
|
||||||
|
discount_value: Some(coupon.discount_value),
|
||||||
|
final_price_inr: final_price,
|
||||||
|
message: "Coupon applied".to_string(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use axum::{
|
||||||
routing::{get, patch, post},
|
routing::{get, patch, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -17,6 +17,8 @@ pub fn public_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/categories", get(public_list_categories))
|
.route("/categories", get(public_list_categories))
|
||||||
.route("/articles", get(public_list_articles))
|
.route("/articles", get(public_list_articles))
|
||||||
|
// /search is an alias for /articles?q=... so frontend can use either
|
||||||
|
.route("/search", get(public_list_articles))
|
||||||
.route("/articles/{slug}", get(public_get_article))
|
.route("/articles/{slug}", get(public_get_article))
|
||||||
.route("/articles/id/{id}", get(public_get_article_by_id))
|
.route("/articles/id/{id}", get(public_get_article_by_id))
|
||||||
}
|
}
|
||||||
|
|
@ -413,9 +415,12 @@ async fn public_get_article_by_id(
|
||||||
// ── Admin: categories ─────────────────────────────────────────────────────────
|
// ── Admin: categories ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn admin_list_categories(
|
async fn admin_list_categories(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let rows = sqlx::query_as::<_, CategoryWithCountRow>(
|
let rows = sqlx::query_as::<_, CategoryWithCountRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -466,10 +471,13 @@ struct CreateCategoryBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_create_category(
|
async fn admin_create_category(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<CreateCategoryBody>,
|
Json(body): Json<CreateCategoryBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let order = body.display_order.unwrap_or(0);
|
let order = body.display_order.unwrap_or(0);
|
||||||
let result = sqlx::query_as::<_, CategoryRow>(
|
let result = sqlx::query_as::<_, CategoryRow>(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -525,11 +533,14 @@ struct UpdateCategoryBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_update_category(
|
async fn admin_update_category(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<UpdateCategoryBody>,
|
Json(body): Json<UpdateCategoryBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let result = sqlx::query_as::<_, CategoryRow>(
|
let result = sqlx::query_as::<_, CategoryRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE kb_categories SET
|
UPDATE kb_categories SET
|
||||||
|
|
@ -582,10 +593,13 @@ async fn admin_update_category(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_delete_category(
|
async fn admin_delete_category(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
struct IdRow { id: Uuid }
|
struct IdRow { id: Uuid }
|
||||||
|
|
@ -629,10 +643,13 @@ struct AdminArticleQuery {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_list_articles(
|
async fn admin_list_articles(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<AdminArticleQuery>,
|
Query(params): Query<AdminArticleQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let q = params.q.as_deref().unwrap_or("").to_lowercase();
|
let q = params.q.as_deref().unwrap_or("").to_lowercase();
|
||||||
let status_filter: Option<String> = params.status.as_deref().map(|s| s.to_string());
|
let status_filter: Option<String> = params.status.as_deref().map(|s| s.to_string());
|
||||||
|
|
||||||
|
|
@ -707,6 +724,9 @@ async fn admin_create_article(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<CreateArticleBody>,
|
Json(body): Json<CreateArticleBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let slug = body
|
let slug = body
|
||||||
.slug
|
.slug
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
|
|
@ -778,10 +798,13 @@ async fn admin_create_article(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_get_article(
|
async fn admin_get_article(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let row = sqlx::query_as::<_, AdminArticleRow>(
|
let row = sqlx::query_as::<_, AdminArticleRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -847,11 +870,14 @@ struct UpdateArticleBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_update_article(
|
async fn admin_update_article(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<UpdateArticleBody>,
|
Json(body): Json<UpdateArticleBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let status: Option<String> = body.status.as_deref().map(|s| s.to_string());
|
let status: Option<String> = body.status.as_deref().map(|s| s.to_string());
|
||||||
let result = sqlx::query_as::<_, InsertedArticleRow>(
|
let result = sqlx::query_as::<_, InsertedArticleRow>(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -919,10 +945,13 @@ async fn admin_update_article(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_delete_article(
|
async fn admin_delete_article(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
struct IdRow { id: Uuid }
|
struct IdRow { id: Uuid }
|
||||||
|
|
@ -978,6 +1007,9 @@ async fn admin_ai_draft_article(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<AiDraftArticleBody>,
|
Json(body): Json<AiDraftArticleBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let category = body.category.as_deref().unwrap_or("General");
|
let category = body.category.as_deref().unwrap_or("General");
|
||||||
let hints = body.topic_hints.as_deref().unwrap_or("");
|
let hints = body.topic_hints.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,10 @@ struct PersonaTypeRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_persona_types(
|
async fn list_persona_types(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
|
||||||
let rows = sqlx::query_as::<_, PersonaTypeRow>(
|
let rows = sqlx::query_as::<_, PersonaTypeRow>(
|
||||||
"SELECT id, code, name, description, is_active FROM persona_types WHERE is_active = true ORDER BY name",
|
"SELECT id, code, name, description, is_active FROM persona_types WHERE is_active = true ORDER BY name",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use axum::{
|
||||||
routing::{get, patch},
|
routing::{get, patch},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -60,6 +60,7 @@ struct CreatePackageBody {
|
||||||
tracecoin_amount: Option<i32>,
|
tracecoin_amount: Option<i32>,
|
||||||
price_inr: i32,
|
price_inr: i32,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
is_active: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -167,9 +168,12 @@ async fn public_list_packages(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_packages(
|
async fn list_packages(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let rows = sqlx::query_as::<_, PackageRow>(
|
let rows = sqlx::query_as::<_, PackageRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
|
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
|
||||||
|
|
@ -207,20 +211,24 @@ async fn list_packages(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_package(
|
async fn create_package(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<CreatePackageBody>,
|
Json(body): Json<CreatePackageBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let package_type = body.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string());
|
let package_type = body.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string());
|
||||||
// Accept tracecoin_amount (admin UI) or tracecoins_amount
|
// Accept tracecoin_amount (admin UI) or tracecoins_amount
|
||||||
let tracecoins_amount = body.tracecoins_amount.or(body.tracecoin_amount).unwrap_or(0);
|
let tracecoins_amount = body.tracecoins_amount.or(body.tracecoin_amount).unwrap_or(0);
|
||||||
// Accept role (admin UI) or role_key
|
// Accept role (admin UI) or role_key
|
||||||
let role_key = body.role_key.or(body.role).unwrap_or_else(|| "ALL".to_string());
|
let role_key = body.role_key.or(body.role).unwrap_or_else(|| "ALL".to_string());
|
||||||
|
let is_active = body.is_active.unwrap_or(true);
|
||||||
|
|
||||||
let row = sqlx::query_as::<_, PackageRow>(
|
let row = sqlx::query_as::<_, PackageRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description)
|
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
|
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -230,6 +238,7 @@ async fn create_package(
|
||||||
.bind(tracecoins_amount)
|
.bind(tracecoins_amount)
|
||||||
.bind(body.price_inr)
|
.bind(body.price_inr)
|
||||||
.bind(&body.description)
|
.bind(&body.description)
|
||||||
|
.bind(is_active)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -257,11 +266,14 @@ async fn create_package(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_package(
|
async fn update_package(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<PatchPackageBody>,
|
Json(body): Json<PatchPackageBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let existing = sqlx::query_as::<_, ExistingPackageRow>(
|
let existing = sqlx::query_as::<_, ExistingPackageRow>(
|
||||||
"SELECT name, role_key, package_type, tracecoins_amount, price_inr, description, is_active FROM pricing_packages WHERE id = $1",
|
"SELECT name, role_key, package_type, tracecoins_amount, price_inr, description, is_active FROM pricing_packages WHERE id = $1",
|
||||||
)
|
)
|
||||||
|
|
@ -315,10 +327,13 @@ async fn update_package(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_package(
|
async fn delete_package(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let result = sqlx::query("UPDATE pricing_packages SET is_active = false WHERE id = $1")
|
let result = sqlx::query("UPDATE pricing_packages SET is_active = false WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
|
|
@ -339,10 +354,13 @@ async fn delete_package(
|
||||||
// ── Report handlers ───────────────────────────────────────────────────────────
|
// ── Report handlers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn report_users(
|
async fn report_users(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<DateRangeQuery>,
|
Query(params): Query<DateRangeQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let from = params.from.as_deref().unwrap_or("2000-01-01");
|
let from = params.from.as_deref().unwrap_or("2000-01-01");
|
||||||
let to = params.to.as_deref().unwrap_or("2099-12-31");
|
let to = params.to.as_deref().unwrap_or("2099-12-31");
|
||||||
|
|
||||||
|
|
@ -397,10 +415,13 @@ async fn report_users(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn report_revenue(
|
async fn report_revenue(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<DateRangeQuery>,
|
Query(params): Query<DateRangeQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let from = params.from.as_deref().unwrap_or("2000-01-01");
|
let from = params.from.as_deref().unwrap_or("2000-01-01");
|
||||||
let to = params.to.as_deref().unwrap_or("2099-12-31");
|
let to = params.to.as_deref().unwrap_or("2099-12-31");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -737,7 +737,50 @@ async fn submit_for_verification(
|
||||||
None => fetch_saved_profile(&state, auth.user_id, &role_key).await,
|
None => fetch_saved_profile(&state, auth.user_id, &role_key).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
let documents = extract_documents(&profile_data);
|
let mut documents = extract_documents(&profile_data);
|
||||||
|
|
||||||
|
// For JOB_SEEKER role: also pull documents uploaded via /api/jobseeker/profile/documents
|
||||||
|
// (stored in job_seeker_documents table, separate from profile_data JSONB)
|
||||||
|
if role_key == "JOB_SEEKER" {
|
||||||
|
if let Ok(rows) = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT d.document_type, d.file_url, d.file_name, d.mime_type
|
||||||
|
FROM job_seeker_documents d
|
||||||
|
JOIN job_seeker_profiles p ON p.id = d.job_seeker_id
|
||||||
|
WHERE p.user_id = $1
|
||||||
|
ORDER BY d.created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
use sqlx::Row;
|
||||||
|
let existing_urls: std::collections::HashSet<String> = if let serde_json::Value::Array(ref arr) = documents {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|d| d.get("value").and_then(|v| v.as_str()).map(String::from))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
std::collections::HashSet::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let serde_json::Value::Array(ref mut arr) = documents {
|
||||||
|
for row in rows {
|
||||||
|
let url: String = row.try_get("file_url").unwrap_or_default();
|
||||||
|
if !url.is_empty() && !existing_urls.contains(&url) {
|
||||||
|
let doc_type: String = row.try_get("document_type").unwrap_or_else(|_| "document".to_string());
|
||||||
|
let file_name: String = row.try_get("file_name").unwrap_or_default();
|
||||||
|
arr.push(serde_json::json!({
|
||||||
|
"type": doc_type,
|
||||||
|
"value": url,
|
||||||
|
"file_name": file_name,
|
||||||
|
"status": "SUBMITTED"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mark profile as PENDING in role-specific table
|
// Mark profile as PENDING in role-specific table
|
||||||
set_profile_status(&state, auth.user_id, &role_key, "PENDING").await;
|
set_profile_status(&state, auth.user_id, &role_key, "PENDING").await;
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -21,105 +21,86 @@ pub fn admin_router() -> Router<AppState> {
|
||||||
pub fn public_router() -> Router<AppState> {
|
pub fn public_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_reviews))
|
.route("/", get(list_reviews))
|
||||||
.route("/professional/{professional_id}", get(list_reviews_by_professional))
|
.route("/submit", post(submit_review))
|
||||||
|
.route("/entity/{entity_type}/{entity_id}", get(list_reviews_by_entity))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
struct ReviewDto {
|
struct ReviewDto {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
professional_id: Uuid,
|
reviewer_user_id: Option<Uuid>,
|
||||||
customer_id: Uuid,
|
reviewer_name: Option<String>,
|
||||||
rating: i16,
|
entity_type: String,
|
||||||
comment: Option<String>,
|
entity_id: Option<String>,
|
||||||
|
subject_type: String,
|
||||||
|
subject_id: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
|
rating: Option<i16>,
|
||||||
|
review_text: Option<String>,
|
||||||
is_published: bool,
|
is_published: bool,
|
||||||
|
status: String,
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct PublicReviewDto {
|
|
||||||
id: Uuid,
|
|
||||||
professional_id: Uuid,
|
|
||||||
rating: i16,
|
|
||||||
comment: Option<String>,
|
|
||||||
created_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct CreateReviewBody {
|
struct CreateReviewBody {
|
||||||
#[allow(dead_code)]
|
entity_type: Option<String>,
|
||||||
lead_request_id: Uuid,
|
entity_id: Option<String>,
|
||||||
|
subject_type: Option<String>,
|
||||||
|
subject_id: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
rating: i16,
|
rating: i16,
|
||||||
comment: Option<String>,
|
review_text: Option<String>,
|
||||||
|
reviewer_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct PatchReviewBody {
|
struct PatchReviewBody {
|
||||||
is_published: Option<bool>,
|
is_published: Option<bool>,
|
||||||
|
status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct PublicListQuery {
|
struct PublicListQuery {
|
||||||
page: Option<i64>,
|
page: Option<i64>,
|
||||||
limit: Option<i64>,
|
limit: Option<i64>,
|
||||||
}
|
entity_type: Option<String>,
|
||||||
|
entity_id: Option<String>,
|
||||||
// ── FromRow structs ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct ReviewRow {
|
|
||||||
id: Uuid,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
lead_request_id: Uuid,
|
|
||||||
customer_id: Uuid,
|
|
||||||
professional_id: Uuid,
|
|
||||||
rating: i16,
|
|
||||||
comment: Option<String>,
|
|
||||||
is_published: bool,
|
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn admin_list_reviews(
|
async fn admin_list_reviews(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<PublicListQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let rows = sqlx::query_as::<_, ReviewRow>(
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.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;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, ReviewDto>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT id, reviewer_user_id, reviewer_name, entity_type, entity_id,
|
||||||
r.id,
|
subject_type, subject_id, title, rating, review_text,
|
||||||
r.lead_request_id,
|
is_published, status, created_at
|
||||||
r.customer_id,
|
FROM reviews
|
||||||
r.professional_id,
|
ORDER BY created_at DESC
|
||||||
r.rating,
|
LIMIT $1 OFFSET $2
|
||||||
r.comment,
|
|
||||||
r.is_published,
|
|
||||||
r.created_at
|
|
||||||
FROM reviews r
|
|
||||||
ORDER BY r.created_at DESC
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match rows {
|
match rows {
|
||||||
Ok(rows) => {
|
Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "reviews": rows }))).into_response(),
|
||||||
let dtos: Vec<ReviewDto> = rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| ReviewDto {
|
|
||||||
id: r.id,
|
|
||||||
professional_id: r.professional_id,
|
|
||||||
customer_id: r.customer_id,
|
|
||||||
rating: r.rating,
|
|
||||||
comment: r.comment,
|
|
||||||
is_published: r.is_published,
|
|
||||||
created_at: r.created_at,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "reviews": dtos }))).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to list reviews: {e}");
|
tracing::error!("Failed to list reviews: {e}");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
|
||||||
|
|
@ -128,43 +109,41 @@ async fn admin_list_reviews(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_create_review(
|
async fn admin_create_review(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<CreateReviewBody>,
|
Json(body): Json<CreateReviewBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
if body.rating < 1 || body.rating > 5 {
|
if body.rating < 1 || body.rating > 5 {
|
||||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response();
|
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let row = sqlx::query_as::<_, ReviewRow>(
|
let row = sqlx::query_as::<_, ReviewDto>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO reviews (lead_request_id, customer_id, professional_id, rating, comment, is_published)
|
INSERT INTO reviews (reviewer_user_id, entity_type, entity_id, subject_type, subject_id,
|
||||||
SELECT $1,
|
title, rating, review_text, reviewer_name, is_published, status)
|
||||||
(SELECT id FROM customer_profiles WHERE user_id = (SELECT customer_user_id FROM lead_requests WHERE id = $1)),
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, 'PUBLISHED')
|
||||||
(SELECT user_role_profile_id FROM lead_requests WHERE id = $1),
|
RETURNING id, reviewer_user_id, reviewer_name, entity_type, entity_id,
|
||||||
$2, $3, true
|
subject_type, subject_id, title, rating, review_text,
|
||||||
RETURNING id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
|
is_published, status, created_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(body.lead_request_id)
|
.bind(auth.user_id)
|
||||||
|
.bind(body.entity_type.as_deref().unwrap_or("PLATFORM"))
|
||||||
|
.bind(&body.entity_id)
|
||||||
|
.bind(body.subject_type.as_deref().unwrap_or("PLATFORM"))
|
||||||
|
.bind(&body.subject_id)
|
||||||
|
.bind(&body.title)
|
||||||
.bind(body.rating)
|
.bind(body.rating)
|
||||||
.bind(&body.comment)
|
.bind(&body.review_text)
|
||||||
|
.bind(&body.reviewer_name)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
Ok(r) => {
|
Ok(r) => (StatusCode::CREATED, Json(r)).into_response(),
|
||||||
let dto = ReviewDto {
|
|
||||||
id: r.id,
|
|
||||||
professional_id: r.professional_id,
|
|
||||||
customer_id: r.customer_id,
|
|
||||||
rating: r.rating,
|
|
||||||
comment: r.comment,
|
|
||||||
is_published: r.is_published,
|
|
||||||
created_at: r.created_at,
|
|
||||||
};
|
|
||||||
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to create review: {e}");
|
tracing::error!("Failed to create review: {e}");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create review" }))).into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create review" }))).into_response()
|
||||||
|
|
@ -173,17 +152,19 @@ async fn admin_create_review(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_update_review(
|
async fn admin_update_review(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<PatchReviewBody>,
|
Json(body): Json<PatchReviewBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let is_published = body.is_published.unwrap_or(true);
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE reviews SET is_published = $1, updated_at = NOW() WHERE id = $2",
|
"UPDATE reviews SET is_published = COALESCE($1, is_published), status = COALESCE($2, status), updated_at = NOW() WHERE id = $3",
|
||||||
)
|
)
|
||||||
.bind(is_published)
|
.bind(body.is_published)
|
||||||
|
.bind(&body.status)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -201,10 +182,13 @@ async fn admin_update_review(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_delete_review(
|
async fn admin_delete_review(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let result = sqlx::query("DELETE FROM reviews WHERE id = $1")
|
let result = sqlx::query("DELETE FROM reviews WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
|
|
@ -232,34 +216,28 @@ async fn list_reviews(
|
||||||
let limit = q.limit.unwrap_or(20).clamp(1, 100);
|
let limit = q.limit.unwrap_or(20).clamp(1, 100);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, ReviewRow>(
|
let rows = sqlx::query_as::<_, ReviewDto>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
|
SELECT id, reviewer_user_id, reviewer_name, entity_type, entity_id,
|
||||||
|
subject_type, subject_id, title, rating, review_text,
|
||||||
|
is_published, status, created_at
|
||||||
FROM reviews
|
FROM reviews
|
||||||
WHERE is_published = true
|
WHERE is_published = true AND status = 'PUBLISHED'
|
||||||
|
AND ($1::text IS NULL OR entity_type = $1)
|
||||||
|
AND ($2::text IS NULL OR entity_id = $2)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $1 OFFSET $2
|
LIMIT $3 OFFSET $4
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(&q.entity_type)
|
||||||
|
.bind(&q.entity_id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match rows {
|
match rows {
|
||||||
Ok(rows) => {
|
Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "reviews": rows }))).into_response(),
|
||||||
let dtos: Vec<PublicReviewDto> = rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| PublicReviewDto {
|
|
||||||
id: r.id,
|
|
||||||
professional_id: r.professional_id,
|
|
||||||
rating: r.rating,
|
|
||||||
comment: r.comment,
|
|
||||||
created_at: r.created_at.to_rfc3339(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "reviews": dtos }))).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to list public reviews: {e}");
|
tracing::error!("Failed to list public reviews: {e}");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
|
||||||
|
|
@ -267,25 +245,69 @@ async fn list_reviews(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_reviews_by_professional(
|
async fn submit_review(
|
||||||
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(professional_id): Path<Uuid>,
|
Json(body): Json<CreateReviewBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if body.rating < 1 || body.rating > 5 {
|
||||||
|
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, ReviewDto>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO reviews (reviewer_user_id, entity_type, entity_id, subject_type, subject_id,
|
||||||
|
title, rating, review_text, reviewer_name, is_published, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, 'PUBLISHED')
|
||||||
|
RETURNING id, reviewer_user_id, reviewer_name, entity_type, entity_id,
|
||||||
|
subject_type, subject_id, title, rating, review_text,
|
||||||
|
is_published, status, created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.bind(body.entity_type.as_deref().unwrap_or("PLATFORM"))
|
||||||
|
.bind(&body.entity_id)
|
||||||
|
.bind(body.subject_type.as_deref().unwrap_or("PLATFORM"))
|
||||||
|
.bind(&body.subject_id)
|
||||||
|
.bind(&body.title)
|
||||||
|
.bind(body.rating)
|
||||||
|
.bind(&body.review_text)
|
||||||
|
.bind(&body.reviewer_name)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Ok(r) => (StatusCode::CREATED, Json(r)).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to submit review: {e}");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to submit review" }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_reviews_by_entity(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((entity_type, entity_id)): Path<(String, String)>,
|
||||||
Query(q): Query<PublicListQuery>,
|
Query(q): Query<PublicListQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let page = q.page.unwrap_or(1).max(1);
|
let page = q.page.unwrap_or(1).max(1);
|
||||||
let limit = q.limit.unwrap_or(20).clamp(1, 100);
|
let limit = q.limit.unwrap_or(20).clamp(1, 100);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, ReviewRow>(
|
let rows = sqlx::query_as::<_, ReviewDto>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
|
SELECT id, reviewer_user_id, reviewer_name, entity_type, entity_id,
|
||||||
|
subject_type, subject_id, title, rating, review_text,
|
||||||
|
is_published, status, created_at
|
||||||
FROM reviews
|
FROM reviews
|
||||||
WHERE professional_id = $1 AND is_published = true
|
WHERE entity_type = $1 AND entity_id = $2
|
||||||
|
AND is_published = true AND status = 'PUBLISHED'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $3 OFFSET $4
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(professional_id)
|
.bind(&entity_type)
|
||||||
|
.bind(&entity_id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
|
|
@ -293,34 +315,32 @@ async fn list_reviews_by_professional(
|
||||||
|
|
||||||
match rows {
|
match rows {
|
||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
let avg: (f64,) = sqlx::query_as("SELECT COALESCE(AVG(rating), 0)::float FROM reviews WHERE professional_id = $1 AND is_published = true")
|
let avg: (f64,) = sqlx::query_as(
|
||||||
.bind(professional_id)
|
"SELECT COALESCE(AVG(rating), 0)::float FROM reviews WHERE entity_type = $1 AND entity_id = $2 AND is_published = true AND status = 'PUBLISHED'"
|
||||||
|
)
|
||||||
|
.bind(&entity_type)
|
||||||
|
.bind(&entity_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or((0.0,));
|
.unwrap_or((0.0,));
|
||||||
let count: (i64,) = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE professional_id = $1 AND is_published = true")
|
|
||||||
.bind(professional_id)
|
let count: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM reviews WHERE entity_type = $1 AND entity_id = $2 AND is_published = true AND status = 'PUBLISHED'"
|
||||||
|
)
|
||||||
|
.bind(&entity_type)
|
||||||
|
.bind(&entity_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or((0,));
|
.unwrap_or((0,));
|
||||||
let dtos: Vec<PublicReviewDto> = rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| PublicReviewDto {
|
|
||||||
id: r.id,
|
|
||||||
professional_id: r.professional_id,
|
|
||||||
rating: r.rating,
|
|
||||||
comment: r.comment,
|
|
||||||
created_at: r.created_at.to_rfc3339(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
"reviews": dtos,
|
"reviews": rows,
|
||||||
"averageRating": avg.0,
|
"averageRating": avg.0,
|
||||||
"totalCount": count.0
|
"totalCount": count.0
|
||||||
}))).into_response()
|
}))).into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to list reviews for professional {professional_id}: {e}");
|
tracing::error!("Failed to list reviews for {entity_type}/{entity_id}: {e}");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use axum::{
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -411,6 +411,8 @@ async fn user_get_ticket(
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AddMessageBody {
|
struct AddMessageBody {
|
||||||
|
/// Accepts `body` or `message` (frontend alias).
|
||||||
|
#[serde(alias = "message")]
|
||||||
body: String,
|
body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -513,10 +515,13 @@ struct AdminTicketRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_list_cases(
|
async fn admin_list_cases(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<AdminListQuery>,
|
Query(params): Query<AdminListQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let page = params.page.unwrap_or(1).max(1);
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
let limit = params.limit.unwrap_or(50).clamp(1, 200);
|
let limit = params.limit.unwrap_or(50).clamp(1, 200);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
|
|
@ -606,10 +611,13 @@ struct AdminCreateCaseBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_create_case(
|
async fn admin_create_case(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<AdminCreateCaseBody>,
|
Json(body): Json<AdminCreateCaseBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let category = body.ticket_type.unwrap_or_else(|| "customer_query".to_string());
|
let category = body.ticket_type.unwrap_or_else(|| "customer_query".to_string());
|
||||||
let priority = body.priority.unwrap_or_else(|| "medium".to_string());
|
let priority = body.priority.unwrap_or_else(|| "medium".to_string());
|
||||||
|
|
||||||
|
|
@ -665,10 +673,13 @@ async fn admin_create_case(
|
||||||
// ── Admin: get case with messages ─────────────────────────────────────────────
|
// ── Admin: get case with messages ─────────────────────────────────────────────
|
||||||
|
|
||||||
async fn admin_get_case(
|
async fn admin_get_case(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let ticket = sqlx::query_as::<_, AdminTicketRow>(
|
let ticket = sqlx::query_as::<_, AdminTicketRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -774,11 +785,14 @@ struct UpdatedTicketRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_update_case(
|
async fn admin_update_case(
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<UpdateCaseBody>,
|
Json(body): Json<UpdateCaseBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let result = sqlx::query_as::<_, UpdatedTicketRow>(
|
let result = sqlx::query_as::<_, UpdatedTicketRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE support_tickets SET
|
UPDATE support_tickets SET
|
||||||
|
|
@ -861,6 +875,9 @@ async fn admin_add_message(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<AdminAddMessageBody>,
|
Json(body): Json<AdminAddMessageBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if let Err(e) = require_admin(&auth) {
|
||||||
|
return e.into_response();
|
||||||
|
}
|
||||||
let is_internal = body.is_internal.unwrap_or(false);
|
let is_internal = body.is_internal.unwrap_or(false);
|
||||||
|
|
||||||
let exists = sqlx::query_scalar::<_, bool>(
|
let exists = sqlx::query_scalar::<_, bool>(
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ async fn main() {
|
||||||
.nest("/api/admin/users", handlers::admin::router())
|
.nest("/api/admin/users", handlers::admin::router())
|
||||||
.nest("/api/me/roles", handlers::user_roles::router())
|
.nest("/api/me/roles", handlers::user_roles::router())
|
||||||
// ── Notifications ─────────────────────────────────────────────────
|
// ── Notifications ─────────────────────────────────────────────────
|
||||||
|
// BUG-05 fix: also mount at /api/notifications so gateway route
|
||||||
|
// /api/notifications → users service works regardless of prefix
|
||||||
|
.nest("/api/notifications", handlers::notifications::router())
|
||||||
.nest("/api/me/notifications", handlers::notifications::router())
|
.nest("/api/me/notifications", handlers::notifications::router())
|
||||||
.nest("/api/me/settings", handlers::settings::router())
|
.nest("/api/me/settings", handlers::settings::router())
|
||||||
// ── Admin: Approvals (jobs/requirements) ─────────────────────────
|
// ── Admin: Approvals (jobs/requirements) ─────────────────────────
|
||||||
|
|
@ -116,6 +119,8 @@ async fn main() {
|
||||||
// ── Coupons & Discounts (admin) ───────────────────────────────────
|
// ── Coupons & Discounts (admin) ───────────────────────────────────
|
||||||
.nest("/api/admin/coupons", handlers::coupons::coupons_router())
|
.nest("/api/admin/coupons", handlers::coupons::coupons_router())
|
||||||
.nest("/api/admin/discounts", handlers::coupons::discounts_router())
|
.nest("/api/admin/discounts", handlers::coupons::discounts_router())
|
||||||
|
// ── Coupons (user-facing, BUG-44 fix) ───────────────────────────────
|
||||||
|
.nest("/api/coupons", handlers::coupons::user_coupons_router())
|
||||||
.nest("/api/admin/payment-gateway-config", handlers::payment_gateway::router())
|
.nest("/api/admin/payment-gateway-config", handlers::payment_gateway::router())
|
||||||
// ── Tracecoin Packages (public) ───────────────────────────────────
|
// ── Tracecoin Packages (public) ───────────────────────────────────
|
||||||
.nest("/api/packages", handlers::pricing::public_packages_router())
|
.nest("/api/packages", handlers::pricing::public_packages_router())
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,21 @@ pub struct LeadRequestPayload {
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct OpenLeadsQuery {
|
||||||
|
pub location: Option<String>,
|
||||||
|
pub page: Option<i64>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard per-lead request cap -- how many professionals can send a
|
||||||
|
/// request before a requirement stops accepting new ones.
|
||||||
|
const MAX_REQUESTS_PER_LEAD: i32 = 10;
|
||||||
|
/// Raised cap for a customer-paid urgent lead (see mark_requirement_urgent
|
||||||
|
/// in apps/customers/src/handlers.rs) -- more competing requests is the
|
||||||
|
/// point of paying to be urgent.
|
||||||
|
const MAX_REQUESTS_PER_LEAD_URGENT: i32 = 20;
|
||||||
|
|
||||||
/// Build the shared Router that every profession service merges into its own Router.
|
/// Build the shared Router that every profession service merges into its own Router.
|
||||||
/// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`.
|
/// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`.
|
||||||
pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
|
pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
|
||||||
|
|
@ -82,6 +97,13 @@ pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
|
||||||
)
|
)
|
||||||
.route("/marketplace/{id}", get(get_requirement))
|
.route("/marketplace/{id}", get(get_requirement))
|
||||||
// ── Lead Requests ────────────────────────────────────────────────────
|
// ── Lead Requests ────────────────────────────────────────────────────
|
||||||
|
.route(
|
||||||
|
"/leads/open",
|
||||||
|
get({
|
||||||
|
let pk = profession_key;
|
||||||
|
move |state, auth, query| list_open_leads(state, auth, query, pk)
|
||||||
|
}),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/leads/request",
|
"/leads/request",
|
||||||
post(
|
post(
|
||||||
|
|
@ -130,6 +152,55 @@ async fn get_requirement(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Browsable feed of open leads (requirements) matching this profession --
|
||||||
|
/// distinct from GET /marketplace above, which lists *professional
|
||||||
|
/// profiles* for customers to browse. This is the other direction:
|
||||||
|
/// professionals browsing open leads to request. Originally built reusing
|
||||||
|
/// the `/marketplace` path, which collided with that existing route and
|
||||||
|
/// crash-looped every profession service on next restart (`Overlapping
|
||||||
|
/// method route` panic, axum panics at router-build time, not
|
||||||
|
/// request time, so this sat latent from deploy until the next restart) --
|
||||||
|
/// moved to /leads/open, alongside this profession's other /leads/*
|
||||||
|
/// endpoints. Urgent (customer-paid) leads sort first, then newest first;
|
||||||
|
/// expired/closed/rejected leads never appear.
|
||||||
|
async fn list_open_leads(
|
||||||
|
State(state): State<ProfessionState>,
|
||||||
|
_auth: AuthUser,
|
||||||
|
Query(query): Query<OpenLeadsQuery>,
|
||||||
|
profession_key: &'static str,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
let location_filter = query.location.map(|l| format!("%{}%", l));
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, db::models::requirement::Requirement>(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM leads
|
||||||
|
WHERE profession_key = $1
|
||||||
|
AND status = 'OPEN'
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
AND ($2::TEXT IS NULL OR location ILIKE $2)
|
||||||
|
ORDER BY is_urgent DESC, created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(profession_key)
|
||||||
|
.bind(&location_filter)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match rows {
|
||||||
|
Ok(rows) => (StatusCode::OK, Json(serde_json::json!({
|
||||||
|
"data": rows,
|
||||||
|
"pagination": { "page": page, "limit": limit }
|
||||||
|
}))).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_lead_request(
|
async fn send_lead_request(
|
||||||
State(state): State<ProfessionState>,
|
State(state): State<ProfessionState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
|
|
@ -186,7 +257,10 @@ async fn send_lead_request(
|
||||||
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if req.request_count >= 20 {
|
// Urgent (customer-paid) leads get a raised request cap -- more
|
||||||
|
// professionals competing for it is the point of paying to be urgent.
|
||||||
|
let request_cap = if req.is_urgent { MAX_REQUESTS_PER_LEAD_URGENT } else { MAX_REQUESTS_PER_LEAD };
|
||||||
|
if req.request_count >= request_cap {
|
||||||
return (StatusCode::CONFLICT, "Requirement reached max requests").into_response();
|
return (StatusCode::CONFLICT, "Requirement reached max requests").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- No-op: 20260814050000's up migration recreates the trigger, and its own
|
||||||
|
-- down migration is responsible for dropping it again.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Prod has an `invoices_payment_id_check` trigger that was created outside
|
||||||
|
-- of sqlx's migration tracking (never recorded in _sqlx_migrations), which
|
||||||
|
-- made 20260814050000_invoices_payment_id_polymorphic_check.up.sql fail
|
||||||
|
-- with "trigger already exists" and stop the whole migrate run partway
|
||||||
|
-- through, blocking every migration after it. Drop the untracked trigger
|
||||||
|
-- here (a version strictly before 050000) so 050000 can recreate it
|
||||||
|
-- cleanly and the run proceeds.
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS invoices_payment_id_check ON invoices;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE pricing_packages SET price_inr = price_inr / 100 WHERE price_inr >= 100 AND price_inr < 1000000 AND price_inr != 25000;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- pricing_packages.price_inr is treated as PAISE by every consumer (see
|
||||||
|
-- apps/payments/src/main.rs's paise_to_rupee_string call, and the comment
|
||||||
|
-- on that column at apps/payments/src/main.rs:196) but 58 of the 59 rows
|
||||||
|
-- (all but the JOB_SEEKER "Starter Pack" seeded correctly at 25000 = ₹250
|
||||||
|
-- in 20260721070000_seed_pricing_packages.up.sql) were inserted with plain
|
||||||
|
-- rupee-looking values (499, 999, 1999, 4999, 9999, ...). PayU would divide
|
||||||
|
-- by 100 and charge 1/100th of the intended price, e.g. a "₹999 Growth"
|
||||||
|
-- package would actually charge ₹9.99.
|
||||||
|
--
|
||||||
|
-- Threshold price_inr < 10000 catches exactly the mis-seeded rows and
|
||||||
|
-- leaves the correctly-seeded 25000 row untouched. Confirmed no purchases
|
||||||
|
-- have gone through pricing_packages yet (this is pre-launch), so this is
|
||||||
|
-- a data-fix, not a refund situation.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE pricing_packages SET price_inr = price_inr * 100 WHERE price_inr < 10000;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE job_applications
|
||||||
|
DROP CONSTRAINT IF EXISTS job_applications_job_id_applicant_user_id_key;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- apps/job_seekers/src/handlers.rs::apply_to_job does a check-then-insert
|
||||||
|
-- (SELECT EXISTS ... then INSERT, no transaction/lock) to block duplicate
|
||||||
|
-- applications, which is race-condition-prone: two concurrent requests can
|
||||||
|
-- both pass the EXISTS check before either INSERT lands, producing two
|
||||||
|
-- applications for the same (job_id, applicant_user_id). The handler's
|
||||||
|
-- INSERT error path already anticipates this - `if e.to_string().contains
|
||||||
|
-- ("unique")` returns 409 ALREADY_APPLIED - but the unique constraint it's
|
||||||
|
-- expecting was never created, so that branch was dead code. This adds it
|
||||||
|
-- as the actual DB-level backstop.
|
||||||
|
--
|
||||||
|
-- Safe to run live: any pre-existing duplicates would violate the new
|
||||||
|
-- constraint and abort the migration, so this fails loudly rather than
|
||||||
|
-- silently if the race has already produced duplicate rows somewhere.
|
||||||
|
--
|
||||||
|
-- Guarded IF NOT EXISTS (Postgres has no ADD CONSTRAINT IF NOT EXISTS) --
|
||||||
|
-- this repo's db-migrate runner has no migration-tracking table and
|
||||||
|
-- re-executes every .up.sql file on every deploy, so every migration must
|
||||||
|
-- be safe to run more than once. This one originally wasn't: a bare ADD
|
||||||
|
-- CONSTRAINT on a second run errored with "already exists" and, since the
|
||||||
|
-- runner aborts the whole batch on first error, silently blocked every
|
||||||
|
-- migration alphabetically after it from ever applying again. Matches the
|
||||||
|
-- DO $$ ... pg_constraint pattern already used elsewhere in this directory
|
||||||
|
-- (e.g. 20260721030000_create_lead_requests.up.sql).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'job_applications_job_id_applicant_user_id_key') THEN
|
||||||
|
ALTER TABLE job_applications
|
||||||
|
ADD CONSTRAINT job_applications_job_id_applicant_user_id_key
|
||||||
|
UNIQUE (job_id, applicant_user_id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE job_applications DROP COLUMN IF EXISTS contact_unlocked_at;
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- apps/companies/src/handlers/mod.rs::view_contact spends a company's
|
||||||
|
-- free_contact_views/purchased_contact_views allowance on every call, with
|
||||||
|
-- no record of which applications' contacts a company has already unlocked.
|
||||||
|
-- That means simply reloading the page re-spends the allowance for the same
|
||||||
|
-- applicant every time (not just a concurrency race -- guaranteed
|
||||||
|
-- double-consumption even one request at a time), and concurrent requests
|
||||||
|
-- can additionally race the check-then-decrement on company_profiles's
|
||||||
|
-- counters past zero.
|
||||||
|
--
|
||||||
|
-- This column lets view_contact skip the charge (and the counter race)
|
||||||
|
-- entirely once an application's contact has already been unlocked by its
|
||||||
|
-- company -- see the accompanying handler fix.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE job_applications
|
||||||
|
ADD COLUMN IF NOT EXISTS contact_unlocked_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE company_profiles
|
||||||
|
DROP CONSTRAINT IF EXISTS company_profiles_free_job_slots_non_negative,
|
||||||
|
DROP CONSTRAINT IF EXISTS company_profiles_purchased_job_slots_non_negative,
|
||||||
|
DROP CONSTRAINT IF EXISTS company_profiles_free_contact_views_non_negative,
|
||||||
|
DROP CONSTRAINT IF EXISTS company_profiles_purchased_contact_views_non_negative;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
-- Defense-in-depth alongside the app-layer fixes to view_contact (contact
|
||||||
|
-- unlock dedup) and create_job (locked quota check): these counters were
|
||||||
|
-- previously decremented with plain `x = x - 1` and no floor, so a bug in
|
||||||
|
-- either handler (or any future caller) could still drive them negative
|
||||||
|
-- with nothing at the DB level to stop it. tracecoin_wallets already has
|
||||||
|
-- this kind of CHECK for balance/reserved -- these four counters are the
|
||||||
|
-- same class of "must never go negative" value and deserve the same
|
||||||
|
-- guarantee, not just app-layer discipline.
|
||||||
|
--
|
||||||
|
-- Safe to run live: fails loudly rather than silently if any row has
|
||||||
|
-- already gone negative, same reasoning as the job_applications unique
|
||||||
|
-- constraint migration.
|
||||||
|
--
|
||||||
|
-- Guarded IF NOT EXISTS (Postgres has no ADD CONSTRAINT IF NOT EXISTS) --
|
||||||
|
-- this repo's db-migrate runner has no migration-tracking table and
|
||||||
|
-- re-executes every .up.sql on every deploy, so every migration must
|
||||||
|
-- tolerate being run more than once. A bare ADD CONSTRAINT here originally
|
||||||
|
-- didn't, which (since the runner aborts the whole batch on first error)
|
||||||
|
-- would have silently blocked every migration alphabetically after this
|
||||||
|
-- one from ever applying on the second deploy onward.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_free_job_slots_non_negative') THEN
|
||||||
|
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_free_job_slots_non_negative CHECK (free_job_slots >= 0);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_purchased_job_slots_non_negative') THEN
|
||||||
|
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_purchased_job_slots_non_negative CHECK (purchased_job_slots >= 0);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_free_contact_views_non_negative') THEN
|
||||||
|
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_free_contact_views_non_negative CHECK (free_contact_views >= 0);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_purchased_contact_views_non_negative') THEN
|
||||||
|
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_purchased_contact_views_non_negative CHECK (purchased_contact_views >= 0);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS lock_tracecoin_wallet(UUID);
|
||||||
|
DROP INDEX IF EXISTS uq_tracecoin_ledger_wallet_reference_type;
|
||||||
|
|
||||||
|
ALTER TABLE tracecoin_ledger
|
||||||
|
DROP COLUMN IF EXISTS balance_after,
|
||||||
|
DROP COLUMN IF EXISTS actor_user_id,
|
||||||
|
DROP COLUMN IF EXISTS metadata;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
-- crates/wallet (lib.rs + hold.rs) is a second, more sophisticated
|
||||||
|
-- Tracecoin implementation (idempotent-by-reference credit/reserve/
|
||||||
|
-- release/confirm/admin_adjust, plus a hold/escrow subsystem) that was
|
||||||
|
-- built against a `tracecoin_ledger` schema with `type`/`reason`/
|
||||||
|
-- `balance_after`/`actor_user_id`/`metadata` columns, and a
|
||||||
|
-- `lock_tracecoin_wallet(uuid)` helper function. Neither the columns nor
|
||||||
|
-- the function were ever actually created: the migration that would have
|
||||||
|
-- added them (20260627000000_tracecoin_security_hardening.up.sql.skip) was
|
||||||
|
-- disabled, and itself assumed columns (`type`, `reason`) that don't match
|
||||||
|
-- this repo's actual, separately-evolved ledger schema (`transaction_type`,
|
||||||
|
-- `reference_type` -- see 20260318233000_tracecoin_ledger_immutable.up.sql).
|
||||||
|
--
|
||||||
|
-- Net effect: every call into crates/wallet errors at the DB layer before
|
||||||
|
-- it can do anything (`function lock_tracecoin_wallet(uuid) does not
|
||||||
|
-- exist`). The one live, reachable caller is the admin manual wallet
|
||||||
|
-- adjustment endpoint (apps/payments/src/admin.rs -> wallet::admin_adjust),
|
||||||
|
-- so every admin credit/debit adjustment 500s today. This migration adds
|
||||||
|
-- what crates/wallet actually needs, reusing the existing
|
||||||
|
-- transaction_type/reference_type columns (via the handler-side fix that
|
||||||
|
-- accompanies this migration) instead of introducing a second, competing
|
||||||
|
-- set of `type`/`reason` columns -- balance_after/actor_user_id/metadata
|
||||||
|
-- are genuinely new, additive audit fields with no existing equivalent.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE tracecoin_ledger
|
||||||
|
ADD COLUMN IF NOT EXISTS balance_after INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS actor_user_id UUID REFERENCES users(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS metadata JSONB;
|
||||||
|
|
||||||
|
-- Idempotency: at most one ledger entry per (wallet, reference,
|
||||||
|
-- transaction_type). Lets a single payment/reservation/admin-action credit
|
||||||
|
-- or debit exactly once on retry, while still allowing separate RESERVE /
|
||||||
|
-- RELEASE / DEBIT rows for the same reference_id (e.g. one lead_request).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_tracecoin_ledger_wallet_reference_type
|
||||||
|
ON tracecoin_ledger (wallet_id, reference_id, transaction_type)
|
||||||
|
WHERE reference_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- The single point where concurrent access to a wallet is serialized:
|
||||||
|
-- get-or-create the wallet row, lock it FOR UPDATE for the rest of the
|
||||||
|
-- caller's transaction, return its current state. Mirrors the inline
|
||||||
|
-- SELECT...FOR UPDATE pattern crates/db/src/models/tracecoin_wallet.rs
|
||||||
|
-- already uses correctly elsewhere in this codebase, just as a reusable
|
||||||
|
-- function for crates/wallet's callers.
|
||||||
|
CREATE OR REPLACE FUNCTION lock_tracecoin_wallet(p_user_id UUID)
|
||||||
|
RETURNS TABLE(wallet_id UUID, balance INTEGER, reserved INTEGER)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
|
||||||
|
VALUES (p_user_id, 0, 0)
|
||||||
|
ON CONFLICT (user_id) DO NOTHING;
|
||||||
|
|
||||||
|
SELECT id INTO v_id FROM tracecoin_wallets WHERE user_id = p_user_id;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT tw.id, tw.balance, tw.reserved
|
||||||
|
FROM tracecoin_wallets tw
|
||||||
|
WHERE tw.id = v_id
|
||||||
|
FOR UPDATE;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_leads_open_feed;
|
||||||
|
|
||||||
|
ALTER TABLE leads
|
||||||
|
DROP COLUMN IF EXISTS is_urgent,
|
||||||
|
DROP COLUMN IF EXISTS urgent_at;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
21
crates/db/migrations/20260818000003_leads_urgent_flag.up.sql
Normal file
21
crates/db/migrations/20260818000003_leads_urgent_flag.up.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- New paid feature: a customer can pay 50 TraceCoins to mark their own
|
||||||
|
-- open requirement ("lead", in professional-facing vocabulary) as urgent.
|
||||||
|
-- Urgent leads: sort first in the professional-facing feed, get a raised
|
||||||
|
-- request cap (20 vs the normal 10), and trigger an immediate notification
|
||||||
|
-- to matching professionals -- see apps/customers/src/handlers.rs
|
||||||
|
-- (mark_requirement_urgent) and crates/contracts/src/profession_shared.rs
|
||||||
|
-- (the new open-leads feed + send_lead_request's cap check).
|
||||||
|
--
|
||||||
|
-- No separate expiry: urgent status lasts exactly as long as the lead
|
||||||
|
-- itself (the existing 7-day expires_at) -- it doesn't extend anything,
|
||||||
|
-- it just changes how the lead is surfaced and capped while it's live.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE leads
|
||||||
|
ADD COLUMN IF NOT EXISTS is_urgent BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS urgent_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_open_feed
|
||||||
|
ON leads (profession_key, status, is_urgent DESC, created_at DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE lead_requests
|
||||||
|
DROP CONSTRAINT IF EXISTS lead_requests_lead_id_fkey,
|
||||||
|
DROP CONSTRAINT IF EXISTS lead_requests_user_role_profile_id_fkey;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- lead_requests.lead_id and .user_role_profile_id were plain UUID columns
|
||||||
|
-- with no DB-level FK to leads(id) / user_role_profiles(id) -- app code
|
||||||
|
-- always populates them correctly today, but nothing stops a future bug
|
||||||
|
-- (or a bad migration/backfill) from writing a dangling reference, and
|
||||||
|
-- Postgres can't catch it without the constraint. professional_user_id and
|
||||||
|
-- customer_user_id already reference users(id); this brings the other two
|
||||||
|
-- foreign keys up to the same level of enforcement.
|
||||||
|
--
|
||||||
|
-- Safe to run live: fails loudly rather than silently if any row already
|
||||||
|
-- has a dangling reference, same reasoning as the job_applications unique
|
||||||
|
-- constraint migration.
|
||||||
|
--
|
||||||
|
-- Guarded IF NOT EXISTS (Postgres has no ADD CONSTRAINT IF NOT EXISTS) --
|
||||||
|
-- this repo's db-migrate runner has no migration-tracking table and
|
||||||
|
-- re-executes every .up.sql on every deploy, so every migration must
|
||||||
|
-- tolerate being run more than once.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lead_requests_lead_id_fkey') THEN
|
||||||
|
ALTER TABLE lead_requests ADD CONSTRAINT lead_requests_lead_id_fkey FOREIGN KEY (lead_id) REFERENCES leads(id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lead_requests_user_role_profile_id_fkey') THEN
|
||||||
|
ALTER TABLE lead_requests ADD CONSTRAINT lead_requests_user_role_profile_id_fkey FOREIGN KEY (user_role_profile_id) REFERENCES user_role_profiles(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS first_name,
|
||||||
|
DROP COLUMN IF EXISTS last_name;
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- Root cause of a live bug: commit a3076ed ("update DB schema - split
|
||||||
|
-- users.first_name, users.last_name, roles split", 2026-04-15) rewrote
|
||||||
|
-- ~35 files across the codebase -- UserRepository::create/get_*, and read
|
||||||
|
-- sites throughout apps/users, apps/companies, apps/job_seekers,
|
||||||
|
-- apps/customers, crates/contracts -- to use users.first_name/last_name
|
||||||
|
-- instead of users.full_name. It never added the migration to actually
|
||||||
|
-- create those columns. users.create (the only INSERT into this table,
|
||||||
|
-- and the live registration path) has been trying to insert into
|
||||||
|
-- first_name/last_name ever since, which errors outright -- confirmed by
|
||||||
|
-- replaying every migration in this directory (the exact set baked into
|
||||||
|
-- the deployed nxtgauge-db-migrate image, see Dockerfile.migrate) against
|
||||||
|
-- a clean Postgres and attempting the same INSERT UserRepository::create
|
||||||
|
-- issues: `column "first_name" of relation "users" does not exist`.
|
||||||
|
--
|
||||||
|
-- This migration finishes what that commit should have done: add the
|
||||||
|
-- columns, backfill from the pre-existing full_name (naive split on the
|
||||||
|
-- first space -- good enough for a one-time backfill, not meant to handle
|
||||||
|
-- every name format perfectly). full_name itself is NOT dropped: other
|
||||||
|
-- code (apps/payments -- billing/invoice legal names, job_seeker_profiles)
|
||||||
|
-- still correctly reads it, so the accompanying fix to
|
||||||
|
-- UserRepository::create also writes full_name going forward so both
|
||||||
|
-- stay populated for any given user, not just whichever one happened to
|
||||||
|
-- be written.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS first_name VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS last_name VARCHAR(255);
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
first_name = COALESCE(first_name, NULLIF(split_part(full_name, ' ', 1), '')),
|
||||||
|
last_name = COALESCE(
|
||||||
|
last_name,
|
||||||
|
NULLIF(trim(substring(full_name FROM length(split_part(full_name, ' ', 1)) + 1)), '')
|
||||||
|
)
|
||||||
|
WHERE full_name IS NOT NULL
|
||||||
|
AND (first_name IS NULL OR last_name IS NULL);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS ai_subscription_history;
|
||||||
|
|
||||||
|
ALTER TABLE user_ai_subscriptions
|
||||||
|
DROP COLUMN IF EXISTS downgrade_scheduled_to,
|
||||||
|
DROP COLUMN IF EXISTS is_trial,
|
||||||
|
DROP COLUMN IF EXISTS trial_days,
|
||||||
|
DROP COLUMN IF EXISTS trial_ends_at;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
-- apps/users/src/ai_subscription.rs (upgrade_plan, schedule_downgrade,
|
||||||
|
-- apply_scheduled_downgrades, cancel_subscription, start_trial,
|
||||||
|
-- expire_trials, get_subscription_history -- the whole plan-change/trial
|
||||||
|
-- lifecycle for AI credits) and the matching cron tasks
|
||||||
|
-- (apps/cron/src/tasks/ai_credits.rs::apply_scheduled_downgrades/
|
||||||
|
-- expire_trials) were written entirely against columns and a table that
|
||||||
|
-- were never actually migrated:
|
||||||
|
-- - user_ai_subscriptions.downgrade_scheduled_to / is_trial / trial_days
|
||||||
|
-- / trial_ends_at don't exist
|
||||||
|
-- - ai_subscription_history doesn't exist as a table at all
|
||||||
|
--
|
||||||
|
-- Confirmed live: apply_scheduled_downgrades and expire_trials have been
|
||||||
|
-- failing on every single hourly cron run ("column ... does not exist").
|
||||||
|
-- Any user hitting upgrade/downgrade/cancel/trial-start through
|
||||||
|
-- apps/users/src/ai_subscription.rs would 500 the same way.
|
||||||
|
--
|
||||||
|
-- Schema below is reconstructed from every INSERT/UPDATE/SELECT against
|
||||||
|
-- these in ai_subscription.rs -- not a guess: from_plan_id is nullable
|
||||||
|
-- (upgrade_plan's LEFT JOIN in get_subscription_history, and the column
|
||||||
|
-- is never NOT NULL in any INSERT), to_plan_id is always bound and
|
||||||
|
-- INNER JOINed so NOT NULL, proration_credits/proration_days_remaining
|
||||||
|
-- are only bound by upgrade_plan (every other call site omits them, so
|
||||||
|
-- they need defaults), created_by is only bound by upgrade_plan/
|
||||||
|
-- schedule_downgrade/cancel_subscription (expire_trials omits it, so
|
||||||
|
-- nullable).
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE user_ai_subscriptions
|
||||||
|
ADD COLUMN IF NOT EXISTS downgrade_scheduled_to UUID REFERENCES ai_plans(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS is_trial BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS trial_days INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_subscription_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
from_plan_id UUID REFERENCES ai_plans(id),
|
||||||
|
to_plan_id UUID NOT NULL REFERENCES ai_plans(id),
|
||||||
|
change_type VARCHAR(20) NOT NULL,
|
||||||
|
proration_credits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
proration_days_remaining INTEGER,
|
||||||
|
effective_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'completed',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_subscription_history_user
|
||||||
|
ON ai_subscription_history(user_id, created_at DESC);
|
||||||
|
|
||||||
|
-- apply_scheduled_downgrades' UPDATE closes out the matching 'scheduled'
|
||||||
|
-- row by (user_id, change_type, status) with no id to key off of --
|
||||||
|
-- guard against ever matching more than one in-flight scheduled downgrade
|
||||||
|
-- per user at a time (schedule_downgrade itself has no such guard either,
|
||||||
|
-- but this index at least makes the lookup correct/fast and documents the
|
||||||
|
-- assumption).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_subscription_history_pending_downgrade
|
||||||
|
ON ai_subscription_history(user_id, change_type, status)
|
||||||
|
WHERE change_type = 'downgrade' AND status = 'scheduled';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -41,7 +41,7 @@ impl CateringServiceRepository {
|
||||||
csp.price_per_head_inr, csp.created_at, csp.updated_at
|
csp.price_per_head_inr, csp.created_at, csp.updated_at
|
||||||
FROM catering_service_profiles csp
|
FROM catering_service_profiles csp
|
||||||
INNER JOIN user_role_profiles urp ON urp.id = csp.user_role_profile_id
|
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'"#,
|
WHERE urp.user_id = $1 AND urp.role_key = 'CATERING_SERVICES'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -50,7 +50,7 @@ impl CateringServiceRepository {
|
||||||
|
|
||||||
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result<CateringServiceProfile, sqlx::Error> {
|
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result<CateringServiceProfile, sqlx::Error> {
|
||||||
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
||||||
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'catering_service'"#,
|
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'CATERING_SERVICES'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ impl CustomerRepository {
|
||||||
INSERT INTO customer_profiles (
|
INSERT INTO customer_profiles (
|
||||||
user_id, first_name, last_name, phone, city, area, preferred_professions, bio, custom_data, status
|
user_id, first_name, last_name, phone, city, area, preferred_professions, bio, custom_data, status
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'PENDING')
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING')
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
first_name = EXCLUDED.first_name,
|
first_name = EXCLUDED.first_name,
|
||||||
last_name = EXCLUDED.last_name,
|
last_name = EXCLUDED.last_name,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ impl DeveloperRepository {
|
||||||
dp.created_at, dp.updated_at
|
dp.created_at, dp.updated_at
|
||||||
FROM developer_profiles dp
|
FROM developer_profiles dp
|
||||||
INNER JOIN user_role_profiles urp ON urp.id = dp.user_role_profile_id
|
INNER JOIN user_role_profiles urp ON urp.id = dp.user_role_profile_id
|
||||||
WHERE urp.user_id = $1 AND urp.role_key = 'developer'"#,
|
WHERE urp.user_id = $1 AND urp.role_key = 'DEVELOPER'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -44,7 +44,7 @@ impl DeveloperRepository {
|
||||||
|
|
||||||
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result<DeveloperProfile, sqlx::Error> {
|
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result<DeveloperProfile, sqlx::Error> {
|
||||||
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
||||||
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'developer'"#,
|
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'DEVELOPER'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -52,9 +52,9 @@ impl DeveloperRepository {
|
||||||
.ok_or(sqlx::Error::RowNotFound)?;
|
.ok_or(sqlx::Error::RowNotFound)?;
|
||||||
|
|
||||||
sqlx::query_as::<_, DeveloperProfile>(
|
sqlx::query_as::<_, DeveloperProfile>(
|
||||||
r#"INSERT INTO developer_profiles (user_role_profile_id, tech_stack, experience_years,
|
r#"INSERT INTO developer_profiles (user_id, user_role_profile_id, tech_stack, experience_years,
|
||||||
availability, hourly_rate_inr, remote_ok)
|
availability, hourly_rate_inr, remote_ok)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (user_role_profile_id) DO UPDATE SET
|
ON CONFLICT (user_role_profile_id) DO UPDATE SET
|
||||||
tech_stack = COALESCE(EXCLUDED.tech_stack, developer_profiles.tech_stack),
|
tech_stack = COALESCE(EXCLUDED.tech_stack, developer_profiles.tech_stack),
|
||||||
experience_years = EXCLUDED.experience_years,
|
experience_years = EXCLUDED.experience_years,
|
||||||
|
|
@ -65,6 +65,7 @@ impl DeveloperRepository {
|
||||||
RETURNING id, user_role_profile_id, tech_stack, experience_years, availability,
|
RETURNING id, user_role_profile_id, tech_stack, experience_years, availability,
|
||||||
hourly_rate_inr, remote_ok, created_at, updated_at"#,
|
hourly_rate_inr, remote_ok, created_at, updated_at"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.bind(user_role_profile.0)
|
.bind(user_role_profile.0)
|
||||||
.bind(&p.tech_stack)
|
.bind(&p.tech_stack)
|
||||||
.bind(p.experience_years)
|
.bind(p.experience_years)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ impl FitnessTrainerRepository {
|
||||||
ftp.created_at, ftp.updated_at
|
ftp.created_at, ftp.updated_at
|
||||||
FROM fitness_trainer_profiles ftp
|
FROM fitness_trainer_profiles ftp
|
||||||
INNER JOIN user_role_profiles urp ON urp.id = ftp.user_role_profile_id
|
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'"#,
|
WHERE urp.user_id = $1 AND urp.role_key = 'FITNESS_TRAINER'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -46,7 +46,7 @@ impl FitnessTrainerRepository {
|
||||||
|
|
||||||
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result<FitnessTrainerProfile, sqlx::Error> {
|
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result<FitnessTrainerProfile, sqlx::Error> {
|
||||||
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
||||||
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'fitness_trainer'"#,
|
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'FITNESS_TRAINER'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ impl GraphicDesignerRepository {
|
||||||
gdp.created_at, gdp.updated_at
|
gdp.created_at, gdp.updated_at
|
||||||
FROM graphic_designer_profiles gdp
|
FROM graphic_designer_profiles gdp
|
||||||
INNER JOIN user_role_profiles urp ON urp.id = gdp.user_role_profile_id
|
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'"#,
|
WHERE urp.user_id = $1 AND urp.role_key = 'GRAPHIC_DESIGNER'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -42,7 +42,7 @@ impl GraphicDesignerRepository {
|
||||||
|
|
||||||
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result<GraphicDesignerProfile, sqlx::Error> {
|
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result<GraphicDesignerProfile, sqlx::Error> {
|
||||||
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
||||||
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'graphic_designer'"#,
|
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'GRAPHIC_DESIGNER'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
|
||||||
|
|
@ -123,4 +123,43 @@ impl LeadRequestRepository {
|
||||||
|
|
||||||
Ok(req)
|
Ok(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transitions status only if the row is currently `from_status`,
|
||||||
|
/// atomically -- the `WHERE status = $3` makes this safe to call from
|
||||||
|
/// two concurrent requests racing the same lead_request (e.g. approve
|
||||||
|
/// racing reject, or a double-click on approve): only one call's
|
||||||
|
/// `UPDATE` matches a row and gets `Some(..)` back, the other gets
|
||||||
|
/// `None` because by the time its `UPDATE` runs the row's status has
|
||||||
|
/// already moved. Callers previously read status in a separate
|
||||||
|
/// `SELECT`, branched in Rust, then called the unconditional
|
||||||
|
/// `update_status` above with no re-check -- the same check-then-act
|
||||||
|
/// shape as the job_applications duplicate-application bug.
|
||||||
|
///
|
||||||
|
/// Also used to compensate: if a wallet debit/release fails after this
|
||||||
|
/// transitions PENDING -> ACCEPTED/REJECTED, callers revert with
|
||||||
|
/// `update_status_from(pool, id, "ACCEPTED", "PENDING")` so a failed
|
||||||
|
/// debit doesn't leave the lead stuck resolved with no Tracecoins
|
||||||
|
/// actually moved.
|
||||||
|
pub async fn update_status_from(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
from_status: &str,
|
||||||
|
to_status: &str,
|
||||||
|
) -> Result<Option<LeadRequest>, sqlx::Error> {
|
||||||
|
let req = sqlx::query_as::<_, LeadRequest>(
|
||||||
|
r#"
|
||||||
|
UPDATE lead_requests
|
||||||
|
SET status = $1, resolved_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $2 AND status = $3
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(to_status)
|
||||||
|
.bind(id)
|
||||||
|
.bind(from_status)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(req)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ impl MakeupArtistRepository {
|
||||||
map.created_at, map.updated_at
|
map.created_at, map.updated_at
|
||||||
FROM makeup_artist_profiles map
|
FROM makeup_artist_profiles map
|
||||||
INNER JOIN user_role_profiles urp ON urp.id = map.user_role_profile_id
|
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'"#,
|
WHERE urp.user_id = $1 AND urp.role_key = 'MAKEUP_ARTIST'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -44,7 +44,7 @@ impl MakeupArtistRepository {
|
||||||
|
|
||||||
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result<MakeupArtistProfile, sqlx::Error> {
|
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result<MakeupArtistProfile, sqlx::Error> {
|
||||||
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
||||||
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'makeup_artist'"#,
|
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'MAKEUP_ARTIST'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,13 @@ pub struct UpdatePortfolioItemPayload {
|
||||||
pub struct CreateServicePayload {
|
pub struct CreateServicePayload {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Accepts `price` or `price_inr` (frontend alias). Stored as integer paise/rupees.
|
||||||
|
#[serde(alias = "price_inr")]
|
||||||
pub price: i32,
|
pub price: i32,
|
||||||
|
/// Accepts `duration_minutes` or `duration_hours` (frontend alias).
|
||||||
|
/// When `duration_hours` is used the value is multiplied by 60 on the frontend
|
||||||
|
/// side to convert to minutes; stored in minutes.
|
||||||
|
#[serde(alias = "duration_hours")]
|
||||||
pub duration_minutes: Option<i32>,
|
pub duration_minutes: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +106,11 @@ pub struct CreateServicePayload {
|
||||||
pub struct UpdateServicePayload {
|
pub struct UpdateServicePayload {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Accepts `price` or `price_inr` (frontend alias).
|
||||||
|
#[serde(alias = "price_inr")]
|
||||||
pub price: Option<i32>,
|
pub price: Option<i32>,
|
||||||
|
/// Accepts `duration_minutes` or `duration_hours` (frontend alias).
|
||||||
|
#[serde(alias = "duration_hours")]
|
||||||
pub duration_minutes: Option<i32>,
|
pub duration_minutes: Option<i32>,
|
||||||
pub is_active: Option<bool>,
|
pub is_active: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
@ -383,172 +393,21 @@ impl ProfessionalRepository {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn try_reserve_tracecoins(
|
// try_reserve_tracecoins / try_debit_reserved_tracecoins /
|
||||||
pool: &PgPool,
|
// try_release_reserved_tracecoins used to live here as a second,
|
||||||
user_id: Uuid,
|
// independently-written copy of what
|
||||||
amount: i32,
|
// db::models::tracecoin_wallet::TracecoinWalletRepository already does
|
||||||
reference_id: Uuid,
|
// correctly -- and this copy's ledger INSERTs used `type`/`reason`
|
||||||
) -> Result<bool, sqlx::Error> {
|
// columns that don't exist on tracecoin_ledger (it's `transaction_type`/
|
||||||
let mut tx = pool.begin().await?;
|
// `reference_type`), so any call into it would have errored at the DB
|
||||||
|
// layer. Confirmed via a repo-wide grep that nothing called
|
||||||
sqlx::query(
|
// `ProfessionalRepository::try_*` -- every real caller (profession_
|
||||||
r#"
|
// shared.rs, customers/handlers.rs) already goes through
|
||||||
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
|
// TracecoinWalletRepository -- so this was dead code and a landmine
|
||||||
VALUES ($1, 0, 0)
|
// rather than a working alternative. Removed instead of fixed in place,
|
||||||
ON CONFLICT (user_id) DO NOTHING
|
// since a second implementation of the same reserve/debit/release
|
||||||
"#,
|
// semantics is itself the problem: it's one more place to keep in sync
|
||||||
)
|
// with the schema and with TracecoinWalletRepository's actual behavior.
|
||||||
.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(
|
pub async fn submit_for_verification(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ pub struct Requirement {
|
||||||
pub rejection_reason: Option<String>,
|
pub rejection_reason: Option<String>,
|
||||||
pub request_count: i32,
|
pub request_count: i32,
|
||||||
pub accepted_count: i32,
|
pub accepted_count: i32,
|
||||||
|
pub is_urgent: bool,
|
||||||
|
pub urgent_at: Option<DateTime<Utc>>,
|
||||||
pub expires_at: Option<DateTime<Utc>>,
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
pub approved_at: Option<DateTime<Utc>>,
|
pub approved_at: Option<DateTime<Utc>>,
|
||||||
pub approved_by: Option<Uuid>,
|
pub approved_by: Option<Uuid>,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ impl SocialMediaManagerRepository {
|
||||||
smmp.created_at, smmp.updated_at
|
smmp.created_at, smmp.updated_at
|
||||||
FROM social_media_manager_profiles smmp
|
FROM social_media_manager_profiles smmp
|
||||||
INNER JOIN user_role_profiles urp ON urp.id = smmp.user_role_profile_id
|
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'"#,
|
WHERE urp.user_id = $1 AND urp.role_key = 'SOCIAL_MEDIA_MANAGER'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -44,7 +44,7 @@ impl SocialMediaManagerRepository {
|
||||||
|
|
||||||
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result<SocialMediaManagerProfile, sqlx::Error> {
|
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result<SocialMediaManagerProfile, sqlx::Error> {
|
||||||
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
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'"#,
|
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'SOCIAL_MEDIA_MANAGER'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, PgPool};
|
use sqlx::{FromRow, PgPool, Postgres, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// How long a purchased Tracecoin bucket stays spendable before it expires.
|
||||||
|
/// Checklist rule: "Purchased credits valid for 3 months." Only applies to
|
||||||
|
/// real purchases (see `create_purchase_bucket`'s callers) -- admin-granted
|
||||||
|
/// adjustments never create a bucket, so they never expire.
|
||||||
|
pub const PURCHASE_VALIDITY_DAYS: i64 = 90;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct Wallet {
|
pub struct Wallet {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|
@ -25,9 +31,93 @@ pub struct LedgerEntry {
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct BucketRow {
|
||||||
|
id: Uuid,
|
||||||
|
amount_remaining: i32,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct TracecoinWalletRepository;
|
pub struct TracecoinWalletRepository;
|
||||||
|
|
||||||
impl TracecoinWalletRepository {
|
impl TracecoinWalletRepository {
|
||||||
|
/// Records a real purchase as a FIFO-consumable bucket with a 3-month
|
||||||
|
/// expiry, in the same transaction as the wallet credit. This is what
|
||||||
|
/// makes "purchased credits expire in 3 months" possible at all --
|
||||||
|
/// `tracecoin_wallets.balance` is a flat number with no purchase-date
|
||||||
|
/// tracking on its own; `tracecoin_buckets` is where that history
|
||||||
|
/// lives. Only call this for an actual purchase (currently just
|
||||||
|
/// apps/payments/src/reconcile.rs's PayU credit path) -- admin_adjust
|
||||||
|
/// and anything else that credits the wallet without a real payment
|
||||||
|
/// behind it should NOT create a bucket, since checklist rule is
|
||||||
|
/// "purchased credits", not all credits.
|
||||||
|
pub async fn create_purchase_bucket(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
user_id: Uuid,
|
||||||
|
amount: i32,
|
||||||
|
source: &str,
|
||||||
|
reference_id: Uuid,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tracecoin_buckets (user_id, source, amount, amount_remaining, reference_id, source_kind, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $3, $4, 'PURCHASE', NOW() + ($5 || ' days')::interval)
|
||||||
|
ON CONFLICT (reference_id) WHERE reference_id IS NOT NULL DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(source)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(reference_id)
|
||||||
|
.bind(PURCHASE_VALIDITY_DAYS.to_string())
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws `amount` down from `user_id`'s purchase buckets, oldest
|
||||||
|
/// (soonest-to-expire) first, without erroring if buckets don't cover
|
||||||
|
/// the whole amount -- any shortfall is coming from a non-bucket-
|
||||||
|
/// tracked credit (e.g. an admin grant), which is fine; buckets only
|
||||||
|
/// exist to know how much of a *purchase* is still unspent when it
|
||||||
|
/// expires, not to gate spending itself (the wallet's real `balance`
|
||||||
|
/// already does that). Call this from the same transaction as any
|
||||||
|
/// call site that permanently debits `balance` for a real spend --
|
||||||
|
/// NOT from a reserve or release, since no money has actually left the
|
||||||
|
/// wallet yet at that point.
|
||||||
|
async fn consume_purchase_buckets_fifo(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
user_id: Uuid,
|
||||||
|
amount: i32,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let mut remaining = amount;
|
||||||
|
let buckets = sqlx::query_as::<_, BucketRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, amount_remaining
|
||||||
|
FROM tracecoin_buckets
|
||||||
|
WHERE user_id = $1 AND amount_remaining > 0
|
||||||
|
ORDER BY expires_at ASC NULLS LAST, created_at ASC
|
||||||
|
FOR UPDATE
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for bucket in buckets {
|
||||||
|
if remaining <= 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let draw = remaining.min(bucket.amount_remaining);
|
||||||
|
sqlx::query("UPDATE tracecoin_buckets SET amount_remaining = amount_remaining - $1 WHERE id = $2")
|
||||||
|
.bind(draw)
|
||||||
|
.bind(bucket.id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
remaining -= draw;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Wallet, sqlx::Error> {
|
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Wallet, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Wallet>(
|
sqlx::query_as::<_, Wallet>(
|
||||||
"SELECT * FROM tracecoin_wallets WHERE user_id = $1",
|
"SELECT * FROM tracecoin_wallets WHERE user_id = $1",
|
||||||
|
|
@ -110,6 +200,86 @@ impl TracecoinWalletRepository {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Debits `amount` directly from `balance` (not via reserve/confirm) --
|
||||||
|
/// for one-shot spends like the urgent-lead upgrade, where there's no
|
||||||
|
/// multi-step hold to manage: check funds, spend, done. Idempotent on
|
||||||
|
/// `reference_id` + `reference_type`: a retry with the same reference
|
||||||
|
/// finds the existing ledger row and returns `true` without debiting
|
||||||
|
/// twice, mirroring the FOR-UPDATE-gated pattern the reserve/debit/
|
||||||
|
/// release trio above already uses.
|
||||||
|
pub async fn try_debit_balance(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
amount: i32,
|
||||||
|
reference_type: &str,
|
||||||
|
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?;
|
||||||
|
|
||||||
|
let already: Option<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM tracecoin_ledger WHERE wallet_id = $1 AND reference_id = $2 AND reference_type = $3",
|
||||||
|
)
|
||||||
|
.bind(wallet.id)
|
||||||
|
.bind(reference_id)
|
||||||
|
.bind(reference_type)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if already.is_some() {
|
||||||
|
tx.commit().await?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if wallet.balance < amount {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE tracecoin_wallets SET 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, transaction_type, amount, reference_type, reference_id)
|
||||||
|
VALUES ($1, 'DEBIT', $2, $3, $4)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(wallet.id)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(reference_type)
|
||||||
|
.bind(reference_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Self::consume_purchase_buckets_fifo(&mut tx, user_id, amount).await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn try_debit_reserved_tracecoins(
|
pub async fn try_debit_reserved_tracecoins(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
|
@ -159,6 +329,12 @@ impl TracecoinWalletRepository {
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// This is the actual permanent spend (reserved -> gone), so this is
|
||||||
|
// the point real purchase-bucket balance gets drawn down, not at
|
||||||
|
// reserve time (reserving doesn't spend anything yet -- see
|
||||||
|
// consume_purchase_buckets_fifo's doc comment).
|
||||||
|
Self::consume_purchase_buckets_fifo(&mut tx, user_id, amount).await?;
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ impl TutorRepository {
|
||||||
tp.created_at, tp.updated_at
|
tp.created_at, tp.updated_at
|
||||||
FROM tutor_profiles tp
|
FROM tutor_profiles tp
|
||||||
INNER JOIN user_role_profiles urp ON urp.id = tp.user_role_profile_id
|
INNER JOIN user_role_profiles urp ON urp.id = tp.user_role_profile_id
|
||||||
WHERE urp.user_id = $1 AND urp.role_key = 'tutor'"#,
|
WHERE urp.user_id = $1 AND urp.role_key = 'TUTOR'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -48,7 +48,7 @@ impl TutorRepository {
|
||||||
|
|
||||||
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertTutorProfilePayload) -> Result<TutorProfile, sqlx::Error> {
|
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertTutorProfilePayload) -> Result<TutorProfile, sqlx::Error> {
|
||||||
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
||||||
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'tutor'"#,
|
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'TUTOR'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ impl UgcContentCreatorRepository {
|
||||||
uccp.created_at, uccp.updated_at
|
uccp.created_at, uccp.updated_at
|
||||||
FROM ugc_content_creator_profiles uccp
|
FROM ugc_content_creator_profiles uccp
|
||||||
INNER JOIN user_role_profiles urp ON urp.id = uccp.user_role_profile_id
|
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'"#,
|
WHERE urp.user_id = $1 AND urp.role_key = 'UGC_CONTENT_CREATOR'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -44,7 +44,7 @@ impl UgcContentCreatorRepository {
|
||||||
|
|
||||||
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertUgcContentCreatorProfilePayload) -> Result<UgcContentCreatorProfile, sqlx::Error> {
|
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertUgcContentCreatorProfilePayload) -> Result<UgcContentCreatorProfile, sqlx::Error> {
|
||||||
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
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'"#,
|
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'UGC_CONTENT_CREATOR'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,23 @@ pub struct UserRepository;
|
||||||
|
|
||||||
impl UserRepository {
|
impl UserRepository {
|
||||||
pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result<User, sqlx::Error> {
|
pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result<User, sqlx::Error> {
|
||||||
|
// full_name is still the column apps/payments (invoice/billing
|
||||||
|
// legal names) and job_seeker_profiles read -- see the migration
|
||||||
|
// that added first_name/last_name for the full story. Writing it
|
||||||
|
// here too, derived from first_name/last_name, keeps both
|
||||||
|
// representations correct for every newly-created user instead of
|
||||||
|
// only whichever one a given caller happens to read.
|
||||||
|
let full_name = match (&payload.first_name, &payload.last_name) {
|
||||||
|
(Some(f), Some(l)) if !l.trim().is_empty() => Some(format!("{f} {l}")),
|
||||||
|
(Some(f), _) => Some(f.clone()),
|
||||||
|
(None, Some(l)) => Some(l.clone()),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
let user = sqlx::query_as::<_, User>(
|
let user = sqlx::query_as::<_, User>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO users (first_name, last_name, email, password_hash, email_verified, phone_verified)
|
INSERT INTO users (first_name, last_name, full_name, email, password_hash, email_verified, phone_verified)
|
||||||
VALUES ($1, $2, $3, $4, false, false)
|
VALUES ($1, $2, $3, $4, $5, false, false)
|
||||||
RETURNING
|
RETURNING
|
||||||
id, reference_number, email, password_hash, first_name, last_name,
|
id, reference_number, email, password_hash, first_name, last_name,
|
||||||
email_verified, phone_verified, status,
|
email_verified, phone_verified, status,
|
||||||
|
|
@ -65,6 +78,7 @@ impl UserRepository {
|
||||||
)
|
)
|
||||||
.bind(&payload.first_name)
|
.bind(&payload.first_name)
|
||||||
.bind(&payload.last_name)
|
.bind(&payload.last_name)
|
||||||
|
.bind(&full_name)
|
||||||
.bind(payload.email.to_lowercase())
|
.bind(payload.email.to_lowercase())
|
||||||
.bind(payload.password_hash)
|
.bind(payload.password_hash)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ impl VideoEditorRepository {
|
||||||
vep.created_at, vep.updated_at
|
vep.created_at, vep.updated_at
|
||||||
FROM video_editor_profiles vep
|
FROM video_editor_profiles vep
|
||||||
INNER JOIN user_role_profiles urp ON urp.id = vep.user_role_profile_id
|
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'"#,
|
WHERE urp.user_id = $1 AND urp.role_key = 'VIDEO_EDITOR'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -42,7 +42,7 @@ impl VideoEditorRepository {
|
||||||
|
|
||||||
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result<VideoEditorProfile, sqlx::Error> {
|
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result<VideoEditorProfile, sqlx::Error> {
|
||||||
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
|
||||||
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'video_editor'"#,
|
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'VIDEO_EDITOR'"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,20 @@ use uuid::Uuid;
|
||||||
// Tax
|
// Tax
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Standard GST rate applied to Tracecoin/AI-credit package purchases.
|
||||||
|
/// Was previously the literal `18.0` duplicated at both invoice-generation
|
||||||
|
/// call sites (apps/payments/src/main.rs and ai_credits.rs) with nothing
|
||||||
|
/// tying them together -- a rate change meant remembering to update both
|
||||||
|
/// (and any future call site). Centralized here instead.
|
||||||
|
///
|
||||||
|
/// Note this is independent of the admin-configurable `tax_rules` table
|
||||||
|
/// (apps/payments/src/admin.rs) -- that CRUD exists but nothing in
|
||||||
|
/// invoice generation reads it yet, so changing a tax rule via that API
|
||||||
|
/// currently has no effect on any invoice, historical or future. Wiring
|
||||||
|
/// that up (or removing it if it's not meant to be live yet) is a product
|
||||||
|
/// decision, not folded into this constant.
|
||||||
|
pub const STANDARD_GST_RATE_PERCENT: f64 = 18.0;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum TaxType {
|
pub enum TaxType {
|
||||||
/// No tax (inter-state supply to an unregistered person — usually not
|
/// No tax (inter-state supply to an unregistered person — usually not
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,7 @@ pub async fn settle(
|
||||||
let ledger_id: Uuid = sqlx::query_scalar(
|
let ledger_id: Uuid = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO tracecoin_ledger (
|
INSERT INTO tracecoin_ledger (
|
||||||
wallet_id, type, amount, balance_after, reason,
|
wallet_id, transaction_type, amount, balance_after, reference_type,
|
||||||
reference_id, actor_user_id, metadata
|
reference_id, actor_user_id, metadata
|
||||||
)
|
)
|
||||||
VALUES ($1, 'DEBIT', $2, $3, 'HOLD_SETTLED', $4, NULL, $5)
|
VALUES ($1, 'DEBIT', $2, $3, 'HOLD_SETTLED', $4, NULL, $5)
|
||||||
|
|
|
||||||
|
|
@ -288,16 +288,16 @@ async fn write_ledger_entry(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, wallet_id,
|
id, wallet_id,
|
||||||
type AS entry_type,
|
transaction_type AS entry_type,
|
||||||
amount,
|
amount,
|
||||||
balance_after,
|
balance_after,
|
||||||
reason,
|
reference_type AS reason,
|
||||||
reference_id,
|
reference_id,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
metadata,
|
metadata,
|
||||||
created_at
|
created_at
|
||||||
FROM tracecoin_ledger
|
FROM tracecoin_ledger
|
||||||
WHERE wallet_id = $1 AND reference_id = $2 AND type = $3
|
WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(wallet_id)
|
.bind(wallet_id)
|
||||||
|
|
@ -314,16 +314,16 @@ async fn write_ledger_entry(
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO tracecoin_ledger (
|
INSERT INTO tracecoin_ledger (
|
||||||
wallet_id, type, amount, balance_after, reason,
|
wallet_id, transaction_type, amount, balance_after, reference_type,
|
||||||
reference_id, actor_user_id, metadata
|
reference_id, actor_user_id, metadata
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING
|
RETURNING
|
||||||
id, wallet_id,
|
id, wallet_id,
|
||||||
type AS entry_type,
|
transaction_type AS entry_type,
|
||||||
amount,
|
amount,
|
||||||
balance_after,
|
balance_after,
|
||||||
reason,
|
reference_type AS reason,
|
||||||
reference_id,
|
reference_id,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
metadata,
|
metadata,
|
||||||
|
|
@ -382,16 +382,16 @@ pub async fn credit(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, wallet_id,
|
id, wallet_id,
|
||||||
type AS entry_type,
|
transaction_type AS entry_type,
|
||||||
amount,
|
amount,
|
||||||
balance_after,
|
balance_after,
|
||||||
reason,
|
reference_type AS reason,
|
||||||
reference_id,
|
reference_id,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
metadata,
|
metadata,
|
||||||
created_at
|
created_at
|
||||||
FROM tracecoin_ledger
|
FROM tracecoin_ledger
|
||||||
WHERE wallet_id = $1 AND reference_id = $2 AND type = 'CREDIT'
|
WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = 'CREDIT'
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(wallet_id)
|
.bind(wallet_id)
|
||||||
|
|
@ -466,16 +466,16 @@ pub async fn reserve(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, wallet_id,
|
id, wallet_id,
|
||||||
type AS entry_type,
|
transaction_type AS entry_type,
|
||||||
amount,
|
amount,
|
||||||
balance_after,
|
balance_after,
|
||||||
reason,
|
reference_type AS reason,
|
||||||
reference_id,
|
reference_id,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
metadata,
|
metadata,
|
||||||
created_at
|
created_at
|
||||||
FROM tracecoin_ledger
|
FROM tracecoin_ledger
|
||||||
WHERE wallet_id = $1 AND reference_id = $2 AND type = 'RESERVE'
|
WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = 'RESERVE'
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(wallet_id)
|
.bind(wallet_id)
|
||||||
|
|
@ -551,16 +551,16 @@ pub async fn release(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, wallet_id,
|
id, wallet_id,
|
||||||
type AS entry_type,
|
transaction_type AS entry_type,
|
||||||
amount,
|
amount,
|
||||||
balance_after,
|
balance_after,
|
||||||
reason,
|
reference_type AS reason,
|
||||||
reference_id,
|
reference_id,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
metadata,
|
metadata,
|
||||||
created_at
|
created_at
|
||||||
FROM tracecoin_ledger
|
FROM tracecoin_ledger
|
||||||
WHERE wallet_id = $1 AND reference_id = $2 AND type = 'RELEASE'
|
WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = 'RELEASE'
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(wallet_id)
|
.bind(wallet_id)
|
||||||
|
|
@ -632,16 +632,16 @@ pub async fn confirm(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, wallet_id,
|
id, wallet_id,
|
||||||
type AS entry_type,
|
transaction_type AS entry_type,
|
||||||
amount,
|
amount,
|
||||||
balance_after,
|
balance_after,
|
||||||
reason,
|
reference_type AS reason,
|
||||||
reference_id,
|
reference_id,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
metadata,
|
metadata,
|
||||||
created_at
|
created_at
|
||||||
FROM tracecoin_ledger
|
FROM tracecoin_ledger
|
||||||
WHERE wallet_id = $1 AND reference_id = $2 AND type = 'DEBIT'
|
WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = 'DEBIT'
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(wallet_id)
|
.bind(wallet_id)
|
||||||
|
|
@ -771,10 +771,10 @@ pub async fn list_ledger(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
tl.id, tl.wallet_id,
|
tl.id, tl.wallet_id,
|
||||||
tl.type AS entry_type,
|
tl.transaction_type AS entry_type,
|
||||||
tl.amount,
|
tl.amount,
|
||||||
tl.balance_after,
|
tl.balance_after,
|
||||||
tl.reason,
|
tl.reference_type AS reason,
|
||||||
tl.reference_id,
|
tl.reference_id,
|
||||||
tl.actor_user_id,
|
tl.actor_user_id,
|
||||||
tl.metadata,
|
tl.metadata,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue