Compare commits
6 commits
main
...
high-perfo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6a23cb99a | ||
|
|
4ee593f407 | ||
|
|
c486f60775 | ||
|
|
4beb380aca | ||
|
|
e8f4262804 | ||
|
|
92072f04bd |
35 changed files with 739 additions and 226 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -218,10 +218,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(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'"
|
||||||
.fetch_one(&state.pool)
|
)
|
||||||
.await
|
.bind(&entity_type)
|
||||||
.unwrap_or((0.0,));
|
.bind(&entity_id)
|
||||||
let count: (i64,) = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE professional_id = $1 AND is_published = true")
|
.fetch_one(&state.pool)
|
||||||
.bind(professional_id)
|
.await
|
||||||
.fetch_one(&state.pool)
|
.unwrap_or((0.0,));
|
||||||
.await
|
|
||||||
.unwrap_or((0,));
|
let count: (i64,) = sqlx::query_as(
|
||||||
let dtos: Vec<PublicReviewDto> = rows
|
"SELECT COUNT(*) FROM reviews WHERE entity_type = $1 AND entity_id = $2 AND is_published = true AND status = 'PUBLISHED'"
|
||||||
.into_iter()
|
)
|
||||||
.map(|r| PublicReviewDto {
|
.bind(&entity_type)
|
||||||
id: r.id,
|
.bind(&entity_id)
|
||||||
professional_id: r.professional_id,
|
.fetch_one(&state.pool)
|
||||||
rating: r.rating,
|
.await
|
||||||
comment: r.comment,
|
.unwrap_or((0,));
|
||||||
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())
|
||||||
|
|
|
||||||
|
|
@ -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,21 @@
|
||||||
|
-- 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.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE job_applications
|
||||||
|
ADD CONSTRAINT job_applications_job_id_applicant_user_id_key
|
||||||
|
UNIQUE (job_id, applicant_user_id);
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue