Compare commits

..

6 commits

Author SHA1 Message Date
Ashwin Kumar Sivakumar
f6a23cb99a fix(db): add missing job_applications unique(job_id, applicant_user_id)
All checks were successful
build-and-release / build (cron) (push) Successful in 1m2s
build-and-release / build (customers) (push) Successful in 2m3s
build-and-release / build (developers) (push) Successful in 2m14s
build-and-release / build (employees) (push) Successful in 2m20s
build-and-release / build (catering-services) (push) Successful in 2m44s
build-and-release / build (gateway) (push) Successful in 1m12s
build-and-release / build (fitness-trainers) (push) Successful in 2m29s
build-and-release / build (jobs) (push) Successful in 1m5s
build-and-release / build (companies) (push) Successful in 4m0s
build-and-release / build (graphic-designers) (push) Successful in 2m32s
build-and-release / build (makeup-artists) (push) Successful in 1m46s
build-and-release / build (payments) (push) Successful in 2m43s
build-and-release / build (photographers) (push) Successful in 2m40s
build-and-release / build (tutors) (push) Successful in 2m47s
build-and-release / build (ugc-content-creators) (push) Successful in 2m46s
backend-integration-tests / ai-credits (push) Successful in 46s
build-and-release / build (video-editors) (push) Successful in 2m45s
build-and-release / build (job-seekers) (push) Successful in 8m2s
build-and-release / build-db-migrate (push) Successful in 3m9s
build-and-release / build (social-media-managers) (push) Successful in 7m54s
build-and-release / build (users) (push) Successful in 8m25s
apply_to_job does check-then-insert with no transaction/lock to block
duplicate applications - race-condition-prone. The INSERT error handler
already anticipated a unique-violation ('unique' in error string -> 409
ALREADY_APPLIED) but the constraint itself was never created, so that
branch was dead code. Adding it now gives the existing handling a real
backstop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:42:53 +05:30
Ashwin Kumar Sivakumar
4ee593f407 fix(db): pricing_packages.price_inr was seeded as rupees, treated as paise
All checks were successful
build-and-release / build (companies) (push) Successful in 2m4s
build-and-release / build (customers) (push) Successful in 2m7s
build-and-release / build (employees) (push) Successful in 2m14s
build-and-release / build (catering-services) (push) Successful in 2m34s
build-and-release / build (cron) (push) Successful in 2m40s
build-and-release / build (developers) (push) Successful in 3m16s
build-and-release / build (gateway) (push) Successful in 1m12s
build-and-release / build (graphic-designers) (push) Successful in 1m44s
build-and-release / build (jobs) (push) Successful in 1m52s
build-and-release / build (fitness-trainers) (push) Successful in 2m38s
build-and-release / build (job-seekers) (push) Successful in 2m25s
build-and-release / build (photographers) (push) Successful in 1m55s
build-and-release / build (makeup-artists) (push) Successful in 2m43s
build-and-release / build (social-media-managers) (push) Successful in 2m56s
build-and-release / build (tutors) (push) Successful in 2m49s
build-and-release / build (payments) (push) Successful in 4m44s
backend-integration-tests / ai-credits (push) Successful in 1m3s
build-and-release / build (video-editors) (push) Successful in 2m55s
build-and-release / build (ugc-content-creators) (push) Successful in 4m26s
build-and-release / build-db-migrate (push) Successful in 3m6s
build-and-release / build (users) (push) Successful in 8m29s
58 of 59 rows were inserted with plain rupee-looking values (499, 999,
1999, ...) but every consumer (apps/payments/src/main.rs) treats
price_inr as paise, so PayU would have charged 1/100th of the intended
price. Confirmed zero rows in payments/orders — pre-launch data fix, not
a refund situation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:23:56 +05:30
Ashwin Kumar Sivakumar
c486f60775 chore: retrigger CI after Forgejo restart
All checks were successful
build-and-release / build (catering-services) (push) Successful in 14s
build-and-release / build (customers) (push) Successful in 12s
build-and-release / build (companies) (push) Successful in 16s
build-and-release / build (developers) (push) Successful in 15s
build-and-release / build (employees) (push) Successful in 5s
build-and-release / build (cron) (push) Successful in 21s
build-and-release / build (fitness-trainers) (push) Successful in 5s
build-and-release / build (gateway) (push) Successful in 5s
build-and-release / build (graphic-designers) (push) Successful in 6s
build-and-release / build (job-seekers) (push) Successful in 6s
build-and-release / build (jobs) (push) Successful in 5s
build-and-release / build (makeup-artists) (push) Successful in 5s
build-and-release / build (payments) (push) Successful in 6s
build-and-release / build (photographers) (push) Successful in 6s
build-and-release / build (social-media-managers) (push) Successful in 6s
build-and-release / build (tutors) (push) Successful in 5s
build-and-release / build (ugc-content-creators) (push) Successful in 6s
build-and-release / build (users) (push) Successful in 6s
build-and-release / build (video-editors) (push) Successful in 5s
build-and-release / build-db-migrate (push) Successful in 8s
backend-integration-tests / ai-credits (push) Successful in 6s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:18:49 +05:30
Ashwin Kumar Sivakumar
4beb380aca chore: trigger CI rebuild after clearing Forgejo disk pressure
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:16:08 +05:30
Ashwin Kumar Sivakumar
e8f4262804 fix(db): drop untracked invoices_payment_id_check trigger before 050000 recreates it
Prod has this trigger from an out-of-band creation that was never recorded
in _sqlx_migrations, so migration 20260814050000 failed with 'trigger
already exists' and stopped the whole migrate run partway through.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 18:37:08 +05:30
Tracewebstudio Dev
92072f04bd fix: testing session bugs (BUG-54 through BUG-56) and API improvements
- BUG-55: Fix coupon min_order_amount display dividing rupees by 100
  (users/handlers/coupons.rs - remove /100 from error message format)
- BUG-56: Fix admin applications endpoint 500 - add reference_number to
  SELECT in list_applications query (companies/handlers/admin.rs)
- BUG-54: KB category requires explicit slug field (documented; by-design)
- Various API handler improvements discovered during end-to-end testing:
  auth, profile, support, reviews, kb, pricing, modules, activity_logs,
  admin, ai_phase4, config handlers in users service
- Job seekers and customers service handler fixes
- Gateway and jobs service improvements
- DB model field additions for professional role types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 14:57:50 +02:00
35 changed files with 739 additions and 226 deletions

View file

@ -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

View file

@ -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(),
} }

View file

@ -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")

View file

@ -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()
} }
} }
} }

View file

@ -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();
} }

View file

@ -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;

View file

@ -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)

View file

@ -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(

View file

@ -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

View file

@ -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))
} }

View file

@ -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(),
})))
}

View file

@ -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("");

View file

@ -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",
) )

View file

@ -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");

View file

@ -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;

View file

@ -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()
} }
} }

View file

@ -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>(

View file

@ -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())

View file

@ -0,0 +1,2 @@
-- No-op: 20260814050000's up migration recreates the trigger, and its own
-- down migration is responsible for dropping it again.

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE job_applications
DROP CONSTRAINT IF EXISTS job_applications_job_id_applicant_user_id_key;
COMMIT;

View file

@ -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;

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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>,
} }

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)