feat: human-readable reference numbers + fix verification document visibility
All checks were successful
build-and-release / build (developers) (push) Successful in 1m42s
build-and-release / build (companies) (push) Successful in 1m48s
build-and-release / build (catering-services) (push) Successful in 1m55s
build-and-release / build (cron) (push) Successful in 2m4s
build-and-release / build (customers) (push) Successful in 2m26s
build-and-release / build (employees) (push) Successful in 1m24s
build-and-release / build (fitness-trainers) (push) Successful in 1m37s
build-and-release / build (gateway) (push) Successful in 1m38s
build-and-release / build (graphic-designers) (push) Successful in 1m54s
build-and-release / build (jobs) (push) Successful in 1m48s
build-and-release / build (leads) (push) Successful in 1m40s
build-and-release / build (job-seekers) (push) Successful in 2m52s
build-and-release / build (photographers) (push) Successful in 1m50s
build-and-release / build (payments) (push) Successful in 2m16s
build-and-release / build (makeup-artists) (push) Successful in 2m48s
build-and-release / build (tutors) (push) Successful in 2m16s
build-and-release / build (social-media-managers) (push) Successful in 2m42s
build-and-release / build (ugc-content-creators) (push) Successful in 2m36s
build-and-release / build (video-editors) (push) Successful in 2m19s
build-and-release / build (users) (push) Successful in 5m10s

Adds a DB-trigger-generated reference_number (NXT-{TYPE}-{YY}-{000001}) to
verifications, support_tickets, payments, job_applications, lead_requests,
and users, replacing raw UUIDs shown to customers/admins. Also fixes
verification-status endpoint to return uploaded documents (previously
omitted, so documents never appeared after submission), and adds a
reference-number lookup endpoint for the AI support assistant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-19 16:34:00 +05:30
parent dbb02e54cc
commit e4c9fc31ee
15 changed files with 564 additions and 29 deletions

View file

@ -100,6 +100,7 @@ impl From<Job> for AdminJobRow {
#[derive(Serialize)]
pub struct AdminApplicationRow {
pub id: Uuid,
pub reference_number: String,
pub job_id: Uuid,
pub job_title: String,
pub company_id: Uuid,
@ -117,6 +118,7 @@ impl From<Application> for AdminApplicationRow {
fn from(a: Application) -> Self {
Self {
id: a.id,
reference_number: a.reference_number,
job_id: a.job_id,
job_title: String::new(),
company_id: Uuid::nil(),

View file

@ -34,6 +34,7 @@ pub struct SendLeadRequestAiPayload {
#[derive(Debug, FromRow)]
pub struct LeadRequestRow {
pub id: Uuid,
pub reference_number: String,
pub lead_id: Uuid,
pub user_role_profile_id: Uuid,
pub customer_user_id: Uuid,
@ -50,6 +51,7 @@ pub struct LeadRequestRow {
#[derive(Debug, Serialize)]
pub struct LeadRequestResponse {
pub id: Uuid,
pub reference_number: String,
pub lead_id: Uuid,
pub user_role_profile_id: Uuid,
pub customer_user_id: Uuid,
@ -82,6 +84,7 @@ pub fn router() -> Router<Arc<AppState>> {
fn lead_request_to_response(row: LeadRequestRow) -> LeadRequestResponse {
LeadRequestResponse {
id: row.id,
reference_number: row.reference_number,
lead_id: row.lead_id,
user_role_profile_id: row.user_role_profile_id,
customer_user_id: row.customer_user_id,

View file

@ -230,6 +230,7 @@ async fn list_ledger(
#[derive(Debug, Serialize, FromRow)]
struct OrderRow {
id: Uuid,
reference_number: String,
user_id: Uuid,
user_email: Option<String>,
user_name: Option<String>,
@ -266,7 +267,7 @@ async fn list_orders(
sqlx::query_as::<_, OrderRow>(
r#"
SELECT
p.id, p.user_id, u.email AS user_email,
p.id, p.reference_number, p.user_id, u.email AS user_email,
NULL::text AS user_name,
p.package_id, NULL::text AS package_name,
p.payu_txnid, p.payu_mihpayid,
@ -288,7 +289,7 @@ async fn list_orders(
sqlx::query_as::<_, OrderRow>(
r#"
SELECT
p.id, p.user_id, u.email AS user_email,
p.id, p.reference_number, p.user_id, u.email AS user_email,
NULL::text AS user_name,
p.package_id, NULL::text AS package_name,
p.payu_txnid, p.payu_mihpayid,
@ -312,6 +313,7 @@ async fn list_orders(
.map(|o| {
serde_json::json!({
"id": o.id,
"reference_number": o.reference_number,
"order_number": o.payu_txnid.clone().unwrap_or_else(|| o.id.to_string()),
"user_id": o.user_id,
"user_email": o.user_email,

View file

@ -84,6 +84,8 @@ struct VerifyPaymentRequest {
struct VerifyPaymentResponse {
verified: bool,
payment_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
reference_number: Option<String>,
status: String,
message: String,
}
@ -91,6 +93,8 @@ struct VerifyPaymentResponse {
#[derive(Debug, Serialize)]
struct PaymentStatusResponse {
payment_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
reference_number: Option<String>,
status: String,
amount: u64,
currency: String,
@ -113,6 +117,7 @@ struct UserContactRow {
#[derive(Debug, FromRow)]
struct PaymentRow {
id: Uuid,
reference_number: String,
user_id: Uuid,
#[allow(dead_code)]
package_id: Option<Uuid>,
@ -398,7 +403,7 @@ async fn verify_payment(
let payment = sqlx::query_as::<_, PaymentRow>(
r#"
SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
FROM payments
WHERE payu_txnid = $1 AND status = 'PENDING'
"#,
@ -436,6 +441,7 @@ async fn verify_payment(
return Ok(Json(VerifyPaymentResponse {
verified: false,
payment_id: payload.mihpayid,
reference_number: Some(payment.reference_number.clone()),
status: "failed".to_string(),
message: "PayU reported a non-success status.".to_string(),
}));
@ -512,6 +518,7 @@ async fn verify_payment(
Ok(Json(VerifyPaymentResponse {
verified: true,
payment_id: payload.mihpayid,
reference_number: Some(payment.reference_number.clone()),
status: "success".to_string(),
message: "Payment verified successfully".to_string(),
}))
@ -524,7 +531,7 @@ async fn get_payment_status(
) -> Result<Json<PaymentStatusResponse>, (StatusCode, String)> {
let payment = sqlx::query_as::<_, PaymentRow>(
r#"
SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
FROM payments
WHERE payu_mihpayid = $1 OR payu_txnid = $1
ORDER BY created_at DESC
@ -541,6 +548,7 @@ async fn get_payment_status(
None => {
return Ok(Json(PaymentStatusResponse {
payment_id,
reference_number: None,
status: "not_found".to_string(),
amount: 0,
currency: "INR".to_string(),
@ -558,7 +566,9 @@ async fn get_payment_status(
Ok(Json(PaymentStatusResponse {
payment_id: payment
.payu_mihpayid
.clone()
.unwrap_or(payment_id),
reference_number: Some(payment.reference_number.clone()),
status: payment.status.to_lowercase(),
amount: payment.amount_inr as u64,
currency: "INR".to_string(),

View file

@ -30,6 +30,7 @@ pub struct ListQuery {
#[derive(Serialize, Deserialize, FromRow)]
pub struct AdminUserRow {
pub id: Uuid,
pub reference_number: String,
pub email: String,
pub first_name: Option<String>,
pub last_name: Option<String>,
@ -50,7 +51,7 @@ async fn list_users(
// Generic list: users + their approved roles
r#"
SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
u.id, u.reference_number, u.email, u.first_name, u.last_name, u.status, u.created_at,
COALESCE(array_agg(r.key) FILTER (WHERE r.key IS NOT NULL), '{}') as roles
FROM users u
LEFT JOIN user_role_assignments ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
@ -81,7 +82,7 @@ async fn list_users(
format!(
r#"
SELECT
u.id, u.email, u.first_name, u.last_name, p.status, u.created_at,
u.id, u.reference_number, u.email, u.first_name, u.last_name, p.status, u.created_at,
ARRAY['{}']::text[] as roles
FROM users u
JOIN {} p ON p.user_id = u.id
@ -111,7 +112,7 @@ async fn list_customers(
let sql = r#"
SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
u.id, u.reference_number, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['CUSTOMER']::text[] as roles
FROM users u
JOIN user_role_assignments ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
@ -139,7 +140,7 @@ async fn list_candidates(
let sql = r#"
SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
u.id, u.reference_number, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['JOB_SEEKER']::text[] as roles
FROM users u
JOIN user_role_assignments ur ON ur.user_id = u.id AND ur.status = 'APPROVED'

View file

@ -755,7 +755,7 @@ async fn ai_create_ticket(
r#"
INSERT INTO support_tickets (user_id, subject, description, category, priority, status)
VALUES ($1, $2, $3, $4, $5, 'new')
RETURNING id, subject, description, category, priority, status,
RETURNING id, reference_number, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
)
@ -772,6 +772,7 @@ async fn ai_create_ticket(
StatusCode::CREATED,
Json(serde_json::json!({
"id": r.id,
"reference_number": r.reference_number,
"subject": r.subject,
"status": r.status,
"ticket_id": r.id,
@ -791,7 +792,7 @@ async fn ai_get_ticket(
) -> impl IntoResponse {
let result = sqlx::query_as::<_, TicketRow>(
r#"
SELECT id, subject, description, category, priority, status,
SELECT id, reference_number, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
FROM support_tickets WHERE id = $1
"#,
@ -805,6 +806,7 @@ async fn ai_get_ticket(
StatusCode::OK,
Json(serde_json::json!({
"id": r.id,
"reference_number": r.reference_number,
"subject": r.subject,
"description": r.description,
"category": r.category,
@ -898,6 +900,7 @@ async fn ai_extract_form(
#[derive(sqlx::FromRow)]
struct TicketRow {
id: Uuid,
reference_number: String,
subject: String,
description: Option<String>,
category: String,
@ -2032,6 +2035,7 @@ fn is_support_intent(message: &str) -> bool {
#[derive(Debug, Serialize, Clone)]
pub(crate) struct CreatedTicket {
id: Uuid,
reference_number: String,
subject: String,
status: String,
}
@ -2050,13 +2054,13 @@ async fn auto_create_support_ticket(
subject_src.to_string()
};
let row = sqlx::query_as::<_, (Uuid, String, String)>(
let row = sqlx::query_as::<_, (Uuid, String, String, String)>(
r#"
INSERT INTO support_tickets
(user_id, subject, description, category, priority, status, created_at, updated_at)
VALUES
($1, $2, $3, 'AI_ASSISTED', 'NORMAL', 'OPEN', NOW(), NOW())
RETURNING id, subject, status
RETURNING id, reference_number, subject, status
"#,
)
.bind(user_id)
@ -2068,8 +2072,9 @@ async fn auto_create_support_ticket(
Ok(CreatedTicket {
id: row.0,
subject: row.1,
status: row.2,
reference_number: row.1,
subject: row.2,
status: row.3,
})
}

View file

@ -422,6 +422,7 @@ async fn submit_for_verification(
StatusCode::CREATED,
Json(serde_json::json!({
"verification_id": v.id,
"reference_number": v.reference_number,
"status": "APPROVED",
"message": "Your profile has been auto-approved for demo access."
})),
@ -494,6 +495,7 @@ async fn submit_for_verification(
StatusCode::CREATED,
Json(serde_json::json!({
"verification_id": v.id,
"reference_number": v.reference_number,
"status": v.status,
"message": "Your profile has been submitted for verification. We will notify you once it has been reviewed."
})),
@ -514,7 +516,7 @@ pub async fn verification_status(
let row = sqlx::query(
r#"
SELECT id, status, notes, rejection_reason, updated_at
SELECT id, reference_number, status, notes, rejection_reason, updated_at, documents
FROM verifications
WHERE user_id = $1 AND role_key = $2
ORDER BY created_at DESC
@ -530,21 +532,27 @@ pub async fn verification_status(
Ok(Some(r)) => {
use sqlx::Row;
let id: Uuid = r.try_get("id").unwrap_or(Uuid::nil());
let reference_number: String = r.try_get("reference_number").unwrap_or_default();
let status: String = r.try_get("status").unwrap_or_default();
let notes: Option<String> = r.try_get("notes").ok().flatten();
let rejection_reason: Option<String> = r.try_get("rejection_reason").ok().flatten();
let updated_at: Option<chrono::DateTime<chrono::Utc>> =
r.try_get("updated_at").ok();
let documents: serde_json::Value = r
.try_get("documents")
.unwrap_or(serde_json::Value::Array(vec![]));
(
StatusCode::OK,
Json(serde_json::json!({
"has_verification": true,
"verification_id": id,
"reference_number": reference_number,
"status": status,
"document_request": notes,
"rejection_reason": rejection_reason,
"updated_at": updated_at,
"documents": documents,
})),
)
.into_response()

View file

@ -29,11 +29,20 @@ pub fn admin_router() -> Router<AppState> {
.route("/{id}/messages", post(admin_add_message))
}
/// AI-assistant reference number lookup, nested at `/api/support` (sibling of
/// `/api/support/tickets`). Authenticates with the same shared
/// `X-AI-Service-Key` header as `ai_create_ticket`.
pub fn reference_router() -> Router<AppState> {
Router::new().route("/reference/{number}", get(ai_reference_lookup))
}
// ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct TicketDto {
id: Uuid,
#[serde(rename = "referenceNumber")]
reference_number: String,
title: String,
description: Option<String>,
#[serde(rename = "type")]
@ -71,6 +80,7 @@ struct MessageDto {
#[derive(sqlx::FromRow)]
struct TicketRow {
id: Uuid,
reference_number: String,
subject: String,
description: Option<String>,
category: String,
@ -132,7 +142,7 @@ async fn ai_create_ticket(
r#"
INSERT INTO support_tickets (user_id, subject, description, category, priority, status)
VALUES ($1, $2, $3, $4, $5, 'new')
RETURNING id, subject, description, category, priority, status,
RETURNING id, reference_number, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
)
@ -149,6 +159,7 @@ async fn ai_create_ticket(
StatusCode::CREATED,
Json(serde_json::json!({
"id": r.id,
"reference_number": r.reference_number,
"subject": r.subject,
"description": r.description,
"category": r.category,
@ -185,7 +196,7 @@ async fn user_create_ticket(
r#"
INSERT INTO support_tickets (user_id, subject, description, category, priority, status)
VALUES ($1, $2, $3, $4, $5, 'new')
RETURNING id, subject, description, category, priority, status,
RETURNING id, reference_number, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
)
@ -221,6 +232,7 @@ async fn user_create_ticket(
StatusCode::CREATED,
Json(TicketDto {
id: r.id,
reference_number: r.reference_number,
title: r.subject,
description: r.description,
ticket_type: r.category,
@ -267,7 +279,7 @@ async fn user_list_tickets(
let rows = sqlx::query_as::<_, TicketRow>(
r#"
SELECT id, subject, description, category, priority, status,
SELECT id, reference_number, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
FROM support_tickets
WHERE user_id = $1
@ -289,6 +301,7 @@ async fn user_list_tickets(
.into_iter()
.map(|r| TicketDto {
id: r.id,
reference_number: r.reference_number,
title: r.subject,
description: r.description,
ticket_type: r.category,
@ -323,7 +336,7 @@ async fn user_get_ticket(
) -> impl IntoResponse {
let ticket = sqlx::query_as::<_, TicketRow>(
r#"
SELECT id, subject, description, category, priority, status,
SELECT id, reference_number, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
FROM support_tickets
WHERE id = $1 AND user_id = $2
@ -363,6 +376,7 @@ async fn user_get_ticket(
let dto = TicketDto {
id: t.id,
reference_number: t.reference_number,
title: t.subject,
description: t.description,
ticket_type: t.category,
@ -483,6 +497,7 @@ struct AdminListQuery {
#[derive(sqlx::FromRow)]
struct AdminTicketRow {
id: Uuid,
reference_number: String,
subject: String,
description: Option<String>,
category: String,
@ -512,7 +527,7 @@ async fn admin_list_cases(
let rows = sqlx::query_as::<_, AdminTicketRow>(
r#"
SELECT
t.id, t.subject, t.description, t.category, t.priority, t.status,
t.id, t.reference_number, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at,
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
@ -548,6 +563,7 @@ async fn admin_list_cases(
let requester_email = r.requester_email.or(r.user_email);
serde_json::json!({
"id": r.id,
"reference_number": r.reference_number,
"title": r.subject,
"description": r.description,
"type": r.category,
@ -603,7 +619,7 @@ async fn admin_create_case(
(subject, description, category, priority, status,
requester_name, requester_email)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, subject, description, category, priority, status,
RETURNING id, reference_number, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
)
@ -622,6 +638,7 @@ async fn admin_create_case(
StatusCode::CREATED,
Json(serde_json::json!({
"id": r.id,
"reference_number": r.reference_number,
"title": r.subject,
"description": r.description,
"type": r.category,
@ -655,7 +672,7 @@ async fn admin_get_case(
let ticket = sqlx::query_as::<_, AdminTicketRow>(
r#"
SELECT
t.id, t.subject, t.description, t.category, t.priority, t.status,
t.id, t.reference_number, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at,
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
@ -701,6 +718,7 @@ async fn admin_get_case(
(StatusCode::OK, Json(serde_json::json!({
"ticket": {
"id": t.id,
"reference_number": t.reference_number,
"title": t.subject,
"description": t.description,
"type": t.category,
@ -743,6 +761,7 @@ struct UpdateCaseBody {
#[derive(sqlx::FromRow)]
struct UpdatedTicketRow {
id: Uuid,
reference_number: String,
subject: String,
category: String,
priority: String,
@ -769,7 +788,7 @@ async fn admin_update_case(
resolved_at = CASE WHEN $2 = 'resolved' AND resolved_at IS NULL THEN NOW() ELSE resolved_at END,
updated_at = NOW()
WHERE id = $1
RETURNING id, subject, category, priority, status,
RETURNING id, reference_number, subject, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
)
@ -798,6 +817,7 @@ async fn admin_update_case(
StatusCode::OK,
Json(serde_json::json!({
"id": r.id,
"reference_number": r.reference_number,
"title": r.subject,
"type": r.category,
"priority": r.priority,
@ -895,7 +915,7 @@ async fn admin_add_message(
// Send email notification to user if this is a non-internal reply
if !is_internal {
if let Ok(ticket) = sqlx::query_as::<_, TicketRow>(
"SELECT id, subject, description, category, priority, status, requester_name, requester_email, assigned_to, created_at, updated_at FROM support_tickets WHERE id = $1"
"SELECT id, reference_number, subject, description, category, priority, status, requester_name, requester_email, assigned_to, created_at, updated_at FROM support_tickets WHERE id = $1"
)
.bind(id)
.fetch_one(&state.pool)
@ -942,3 +962,283 @@ async fn admin_add_message(
}
}
}
// ── AI Assistant: reference number lookup (no user auth required) ────────────
//
// Dispatches a `NXT-{TYPE}-{YY}-{seq}` reference number to the right table
// based on its {TYPE} segment, and requires the caller-supplied user_id to
// own the record — otherwise 404 (never leak another user's record).
#[derive(Deserialize)]
struct ReferenceLookupQuery {
#[serde(alias = "userId")]
user_id: Option<Uuid>,
}
async fn ai_reference_lookup(
State(state): State<AppState>,
headers: HeaderMap,
Path(number): Path<String>,
Query(q): Query<ReferenceLookupQuery>,
) -> impl IntoResponse {
let expected_key = std::env::var("AI_SERVICE_KEY").unwrap_or_default();
let provided_key = headers
.get("X-AI-Service-Key")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if expected_key.is_empty() || provided_key != expected_key {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "Invalid or missing AI service key" })),
)
.into_response();
}
// Accept user_id as a query param or an X-User-Id header (mirrors the
// userId-in-body pattern used by ai_create_ticket, adapted for a GET).
let user_id = q.user_id.or_else(|| {
headers
.get("X-User-Id")
.and_then(|v| v.to_str().ok())
.and_then(|s| Uuid::parse_str(s).ok())
});
let user_id = match user_id {
Some(uid) => uid,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "user_id is required" })),
)
.into_response();
}
};
// NXT-{TYPE}-{YY}-{seq} -> {TYPE}
let prefix = number.split('-').nth(1).unwrap_or("").to_uppercase();
let result = match prefix.as_str() {
"VER" => lookup_verification_reference(&state, &number, user_id).await,
"TCK" => lookup_ticket_reference(&state, &number, user_id).await,
"PAY" => lookup_payment_reference(&state, &number, user_id).await,
"JOB" => lookup_job_application_reference(&state, &number, user_id).await,
"LED" => lookup_lead_request_reference(&state, &number, user_id).await,
_ => Err(StatusCode::NOT_FOUND),
};
match result {
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
Err(status) => (
status,
Json(serde_json::json!({ "error": "Reference number not found" })),
)
.into_response(),
}
}
async fn lookup_verification_reference(
state: &AppState,
number: &str,
user_id: Uuid,
) -> Result<serde_json::Value, StatusCode> {
use sqlx::Row;
let row = sqlx::query(
r#"
SELECT status, notes, rejection_reason, created_at
FROM verifications
WHERE reference_number = $1 AND user_id = $2
"#,
)
.bind(number)
.bind(user_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| {
tracing::error!("lookup_verification_reference db error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let status: String = row.try_get("status").unwrap_or_default();
let notes: Option<String> = row.try_get("notes").ok().flatten();
let rejection_reason: Option<String> = row.try_get("rejection_reason").ok().flatten();
let created_at: chrono::DateTime<chrono::Utc> =
row.try_get("created_at").unwrap_or_else(|_| chrono::Utc::now());
let summary = if status.eq_ignore_ascii_case("rejected") {
match rejection_reason {
Some(reason) => format!("Verification was rejected: {}", reason),
None => "Verification was rejected.".to_string(),
}
} else if let Some(n) = notes {
format!("Verification is {}{}", status.to_lowercase(), n)
} else {
format!("Verification is currently {}.", status.to_lowercase())
};
Ok(serde_json::json!({
"type": "VERIFICATION",
"reference_number": number,
"status": status,
"summary": summary,
"created_at": created_at.to_rfc3339(),
}))
}
async fn lookup_ticket_reference(
state: &AppState,
number: &str,
user_id: Uuid,
) -> Result<serde_json::Value, StatusCode> {
use sqlx::Row;
let row = sqlx::query(
r#"
SELECT subject, status, created_at
FROM support_tickets
WHERE reference_number = $1 AND user_id = $2
"#,
)
.bind(number)
.bind(user_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| {
tracing::error!("lookup_ticket_reference db error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let subject: String = row.try_get("subject").unwrap_or_default();
let status: String = row.try_get("status").unwrap_or_default();
let created_at: chrono::DateTime<chrono::Utc> =
row.try_get("created_at").unwrap_or_else(|_| chrono::Utc::now());
Ok(serde_json::json!({
"type": "TICKET",
"reference_number": number,
"status": status,
"summary": format!("Support ticket \"{}\" is {}.", subject, status.to_lowercase()),
"created_at": created_at.to_rfc3339(),
}))
}
async fn lookup_payment_reference(
state: &AppState,
number: &str,
user_id: Uuid,
) -> Result<serde_json::Value, StatusCode> {
use sqlx::Row;
let row = sqlx::query(
r#"
SELECT status, amount_inr, created_at
FROM payments
WHERE reference_number = $1 AND user_id = $2
"#,
)
.bind(number)
.bind(user_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| {
tracing::error!("lookup_payment_reference db error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let status: String = row.try_get("status").unwrap_or_default();
let amount_inr: i32 = row.try_get("amount_inr").unwrap_or_default();
let created_at: chrono::DateTime<chrono::Utc> =
row.try_get("created_at").unwrap_or_else(|_| chrono::Utc::now());
Ok(serde_json::json!({
"type": "PAYMENT",
"reference_number": number,
"status": status,
"summary": format!("Payment of ₹{} is {}.", amount_inr, status.to_lowercase()),
"created_at": created_at.to_rfc3339(),
}))
}
async fn lookup_job_application_reference(
state: &AppState,
number: &str,
user_id: Uuid,
) -> Result<serde_json::Value, StatusCode> {
use sqlx::Row;
let row = sqlx::query(
r#"
SELECT status, applied_at
FROM job_applications
WHERE reference_number = $1 AND applicant_user_id = $2
"#,
)
.bind(number)
.bind(user_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| {
tracing::error!("lookup_job_application_reference db error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let status: String = row.try_get("status").unwrap_or_default();
let applied_at: chrono::DateTime<chrono::Utc> =
row.try_get("applied_at").unwrap_or_else(|_| chrono::Utc::now());
Ok(serde_json::json!({
"type": "JOB_APPLICATION",
"reference_number": number,
"status": status,
"summary": format!("Job application is {}.", status.to_lowercase()),
"created_at": applied_at.to_rfc3339(),
}))
}
async fn lookup_lead_request_reference(
state: &AppState,
number: &str,
user_id: Uuid,
) -> Result<serde_json::Value, StatusCode> {
use sqlx::Row;
let row = sqlx::query(
r#"
SELECT status, message, created_at
FROM lead_requests
WHERE reference_number = $1 AND customer_user_id = $2
"#,
)
.bind(number)
.bind(user_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| {
tracing::error!("lookup_lead_request_reference db error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
let status: String = row.try_get("status").unwrap_or_default();
let message: Option<String> = row.try_get("message").ok().flatten();
let created_at: chrono::DateTime<chrono::Utc> =
row.try_get("created_at").unwrap_or_else(|_| chrono::Utc::now());
let summary = match message {
Some(m) => format!("Lead request is {}{}", status.to_lowercase(), m),
None => format!("Lead request is {}.", status.to_lowercase()),
};
Ok(serde_json::json!({
"type": "LEAD_REQUEST",
"reference_number": number,
"status": status,
"summary": summary,
"created_at": created_at.to_rfc3339(),
}))
}

View file

@ -108,6 +108,8 @@ async fn main() {
.nest("/api/support/tickets", handlers::support::user_router())
// ── Support Tickets (admin) ───────────────────────────────────────
.nest("/api/admin/support-cases", handlers::support::admin_router())
// ── Reference number lookup (AI assistant) ────────────────────────
.nest("/api/support", handlers::support::reference_router())
// ── Reviews (public + admin) ─────────────────────────────────────
.nest("/api/reviews", handlers::reviews::public_router())
.nest("/api/admin/reviews", handlers::reviews::admin_router())

View file

@ -0,0 +1,27 @@
DROP TRIGGER IF EXISTS trg_verifications_reference_number ON verifications;
DROP TRIGGER IF EXISTS trg_support_tickets_reference_number ON support_tickets;
DROP TRIGGER IF EXISTS trg_payments_reference_number ON payments;
DROP TRIGGER IF EXISTS trg_job_applications_reference_number ON job_applications;
DROP TRIGGER IF EXISTS trg_lead_requests_reference_number ON lead_requests;
DROP TRIGGER IF EXISTS trg_users_reference_number ON users;
DROP FUNCTION IF EXISTS set_verification_reference_number();
DROP FUNCTION IF EXISTS set_ticket_reference_number();
DROP FUNCTION IF EXISTS set_payment_reference_number();
DROP FUNCTION IF EXISTS set_job_application_reference_number();
DROP FUNCTION IF EXISTS set_lead_request_reference_number();
DROP FUNCTION IF EXISTS set_user_reference_number();
ALTER TABLE verifications DROP COLUMN IF EXISTS reference_number;
ALTER TABLE support_tickets DROP COLUMN IF EXISTS reference_number;
ALTER TABLE payments DROP COLUMN IF EXISTS reference_number;
ALTER TABLE job_applications DROP COLUMN IF EXISTS reference_number;
ALTER TABLE lead_requests DROP COLUMN IF EXISTS reference_number;
ALTER TABLE users DROP COLUMN IF EXISTS reference_number;
DROP SEQUENCE IF EXISTS verification_number_seq;
DROP SEQUENCE IF EXISTS ticket_number_seq;
DROP SEQUENCE IF EXISTS payment_number_seq;
DROP SEQUENCE IF EXISTS job_application_number_seq;
DROP SEQUENCE IF EXISTS lead_request_number_seq;
DROP SEQUENCE IF EXISTS user_number_seq;

View file

@ -0,0 +1,171 @@
-- Human-readable, sequential, unique reference numbers for user-facing entities.
-- Format: NXT-{TYPE}-{YY}-{000001}. Generated via BEFORE INSERT trigger so every
-- existing and future insert path (there are many, scattered across apps) picks
-- one up automatically without needing per-call-site application changes.
-- ============ verifications ============
CREATE SEQUENCE IF NOT EXISTS verification_number_seq;
ALTER TABLE verifications ADD COLUMN IF NOT EXISTS reference_number VARCHAR;
CREATE OR REPLACE FUNCTION set_verification_reference_number() RETURNS TRIGGER AS $$
BEGIN
IF NEW.reference_number IS NULL THEN
NEW.reference_number := 'NXT-VER-' || to_char(NOW(), 'YY') || '-' || lpad(nextval('verification_number_seq')::text, 6, '0');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_verifications_reference_number ON verifications;
CREATE TRIGGER trg_verifications_reference_number
BEFORE INSERT ON verifications
FOR EACH ROW EXECUTE FUNCTION set_verification_reference_number();
-- ============ support_tickets ============
CREATE SEQUENCE IF NOT EXISTS ticket_number_seq;
ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS reference_number VARCHAR;
CREATE OR REPLACE FUNCTION set_ticket_reference_number() RETURNS TRIGGER AS $$
BEGIN
IF NEW.reference_number IS NULL THEN
NEW.reference_number := 'NXT-TCK-' || to_char(NOW(), 'YY') || '-' || lpad(nextval('ticket_number_seq')::text, 6, '0');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_support_tickets_reference_number ON support_tickets;
CREATE TRIGGER trg_support_tickets_reference_number
BEFORE INSERT ON support_tickets
FOR EACH ROW EXECUTE FUNCTION set_ticket_reference_number();
-- ============ payments ============
CREATE SEQUENCE IF NOT EXISTS payment_number_seq;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS reference_number VARCHAR;
CREATE OR REPLACE FUNCTION set_payment_reference_number() RETURNS TRIGGER AS $$
BEGIN
IF NEW.reference_number IS NULL THEN
NEW.reference_number := 'NXT-PAY-' || to_char(NOW(), 'YY') || '-' || lpad(nextval('payment_number_seq')::text, 6, '0');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_payments_reference_number ON payments;
CREATE TRIGGER trg_payments_reference_number
BEFORE INSERT ON payments
FOR EACH ROW EXECUTE FUNCTION set_payment_reference_number();
-- ============ job_applications ============
CREATE SEQUENCE IF NOT EXISTS job_application_number_seq;
ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS reference_number VARCHAR;
CREATE OR REPLACE FUNCTION set_job_application_reference_number() RETURNS TRIGGER AS $$
BEGIN
IF NEW.reference_number IS NULL THEN
NEW.reference_number := 'NXT-JOB-' || to_char(NOW(), 'YY') || '-' || lpad(nextval('job_application_number_seq')::text, 6, '0');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_job_applications_reference_number ON job_applications;
CREATE TRIGGER trg_job_applications_reference_number
BEFORE INSERT ON job_applications
FOR EACH ROW EXECUTE FUNCTION set_job_application_reference_number();
-- ============ lead_requests ============
CREATE SEQUENCE IF NOT EXISTS lead_request_number_seq;
ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS reference_number VARCHAR;
CREATE OR REPLACE FUNCTION set_lead_request_reference_number() RETURNS TRIGGER AS $$
BEGIN
IF NEW.reference_number IS NULL THEN
NEW.reference_number := 'NXT-LED-' || to_char(NOW(), 'YY') || '-' || lpad(nextval('lead_request_number_seq')::text, 6, '0');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_lead_requests_reference_number ON lead_requests;
CREATE TRIGGER trg_lead_requests_reference_number
BEFORE INSERT ON lead_requests
FOR EACH ROW EXECUTE FUNCTION set_lead_request_reference_number();
-- ============ users ============
CREATE SEQUENCE IF NOT EXISTS user_number_seq;
ALTER TABLE users ADD COLUMN IF NOT EXISTS reference_number VARCHAR;
CREATE OR REPLACE FUNCTION set_user_reference_number() RETURNS TRIGGER AS $$
BEGIN
IF NEW.reference_number IS NULL THEN
NEW.reference_number := 'NXT-USR-' || to_char(NOW(), 'YY') || '-' || lpad(nextval('user_number_seq')::text, 6, '0');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_users_reference_number ON users;
CREATE TRIGGER trg_users_reference_number
BEFORE INSERT ON users
FOR EACH ROW EXECUTE FUNCTION set_user_reference_number();
-- ============ backfill existing rows, oldest first ============
DO $$
DECLARE r RECORD;
BEGIN
FOR r IN SELECT id, created_at FROM verifications WHERE reference_number IS NULL ORDER BY created_at, id LOOP
UPDATE verifications SET reference_number = 'NXT-VER-' || to_char(r.created_at, 'YY') || '-' || lpad(nextval('verification_number_seq')::text, 6, '0') WHERE id = r.id;
END LOOP;
FOR r IN SELECT id, created_at FROM support_tickets WHERE reference_number IS NULL ORDER BY created_at, id LOOP
UPDATE support_tickets SET reference_number = 'NXT-TCK-' || to_char(r.created_at, 'YY') || '-' || lpad(nextval('ticket_number_seq')::text, 6, '0') WHERE id = r.id;
END LOOP;
FOR r IN SELECT id, created_at FROM payments WHERE reference_number IS NULL ORDER BY created_at, id LOOP
UPDATE payments SET reference_number = 'NXT-PAY-' || to_char(r.created_at, 'YY') || '-' || lpad(nextval('payment_number_seq')::text, 6, '0') WHERE id = r.id;
END LOOP;
FOR r IN SELECT id, created_at FROM job_applications WHERE reference_number IS NULL ORDER BY created_at, id LOOP
UPDATE job_applications SET reference_number = 'NXT-JOB-' || to_char(r.created_at, 'YY') || '-' || lpad(nextval('job_application_number_seq')::text, 6, '0') WHERE id = r.id;
END LOOP;
FOR r IN SELECT id, created_at FROM lead_requests WHERE reference_number IS NULL ORDER BY created_at, id LOOP
UPDATE lead_requests SET reference_number = 'NXT-LED-' || to_char(r.created_at, 'YY') || '-' || lpad(nextval('lead_request_number_seq')::text, 6, '0') WHERE id = r.id;
END LOOP;
FOR r IN SELECT id, created_at FROM users WHERE reference_number IS NULL ORDER BY created_at, id LOOP
UPDATE users SET reference_number = 'NXT-USR-' || to_char(r.created_at, 'YY') || '-' || lpad(nextval('user_number_seq')::text, 6, '0') WHERE id = r.id;
END LOOP;
END $$;
-- ============ enforce NOT NULL + UNIQUE now that every row has one ============
ALTER TABLE verifications ALTER COLUMN reference_number SET NOT NULL;
ALTER TABLE support_tickets ALTER COLUMN reference_number SET NOT NULL;
ALTER TABLE payments ALTER COLUMN reference_number SET NOT NULL;
ALTER TABLE job_applications ALTER COLUMN reference_number SET NOT NULL;
ALTER TABLE lead_requests ALTER COLUMN reference_number SET NOT NULL;
ALTER TABLE users ALTER COLUMN reference_number SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'verifications_reference_number_key') THEN
ALTER TABLE verifications ADD CONSTRAINT verifications_reference_number_key UNIQUE (reference_number);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'support_tickets_reference_number_key') THEN
ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_reference_number_key UNIQUE (reference_number);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_reference_number_key') THEN
ALTER TABLE payments ADD CONSTRAINT payments_reference_number_key UNIQUE (reference_number);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'job_applications_reference_number_key') THEN
ALTER TABLE job_applications ADD CONSTRAINT job_applications_reference_number_key UNIQUE (reference_number);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lead_requests_reference_number_key') THEN
ALTER TABLE lead_requests ADD CONSTRAINT lead_requests_reference_number_key UNIQUE (reference_number);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_reference_number_key') THEN
ALTER TABLE users ADD CONSTRAINT users_reference_number_key UNIQUE (reference_number);
END IF;
END $$;

View file

@ -6,6 +6,7 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Application {
pub id: Uuid,
pub reference_number: String,
pub job_id: Uuid,
pub applicant_user_id: Uuid,
pub cover_note: Option<String>,

View file

@ -6,6 +6,7 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct LeadRequest {
pub id: Uuid,
pub reference_number: String,
pub lead_id: Option<Uuid>,
pub user_role_profile_id: Option<Uuid>,
pub professional_user_id: Option<Uuid>,

View file

@ -8,6 +8,7 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: Uuid,
pub reference_number: String,
pub email: String,
pub password_hash: String,
pub first_name: Option<String>,
@ -55,7 +56,7 @@ impl UserRepository {
INSERT INTO users (first_name, last_name, email, password_hash, email_verified, phone_verified)
VALUES ($1, $2, $3, $4, false, false)
RETURNING
id, email, password_hash, first_name, last_name,
id, reference_number, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,
@ -75,7 +76,7 @@ impl UserRepository {
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT id, email, password_hash, first_name, last_name,
SELECT id, reference_number, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,
@ -92,7 +93,7 @@ impl UserRepository {
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT id, email, password_hash, first_name, last_name,
SELECT id, reference_number, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,
@ -149,7 +150,7 @@ impl UserRepository {
pub async fn get_by_verification_token(pool: &PgPool, token: &str) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT id, email, password_hash, first_name, last_name,
SELECT id, reference_number, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,
@ -197,7 +198,7 @@ impl UserRepository {
pub async fn get_by_reset_token(pool: &PgPool, token: &str) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT id, email, password_hash, first_name, last_name,
SELECT id, reference_number, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,

View file

@ -6,6 +6,7 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Verification {
pub id: Uuid,
pub reference_number: String,
pub user_id: Uuid,
pub role_key: String,
pub status: String,