From e4c9fc31eedc3bdaa2fa937fe4a85f574906436c Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Sun, 19 Jul 2026 16:34:00 +0530 Subject: [PATCH] feat: human-readable reference numbers + fix verification document visibility 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 --- apps/companies/src/handlers/admin.rs | 2 + apps/leads/src/lead_requests.rs | 3 + apps/payments/src/admin.rs | 6 +- apps/payments/src/main.rs | 14 +- apps/users/src/handlers/admin.rs | 9 +- apps/users/src/handlers/ai.rs | 17 +- apps/users/src/handlers/profile.rs | 10 +- apps/users/src/handlers/support.rs | 318 +++++++++++++++++- apps/users/src/main.rs | 2 + .../20260719120000_reference_numbers.down.sql | 27 ++ .../20260719120000_reference_numbers.up.sql | 171 ++++++++++ crates/db/src/models/application.rs | 1 + crates/db/src/models/lead_request.rs | 1 + crates/db/src/models/user.rs | 11 +- crates/db/src/models/verification.rs | 1 + 15 files changed, 564 insertions(+), 29 deletions(-) create mode 100644 crates/db/migrations/20260719120000_reference_numbers.down.sql create mode 100644 crates/db/migrations/20260719120000_reference_numbers.up.sql diff --git a/apps/companies/src/handlers/admin.rs b/apps/companies/src/handlers/admin.rs index 9f0a516..4ca227a 100644 --- a/apps/companies/src/handlers/admin.rs +++ b/apps/companies/src/handlers/admin.rs @@ -100,6 +100,7 @@ impl From 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 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(), diff --git a/apps/leads/src/lead_requests.rs b/apps/leads/src/lead_requests.rs index c12453c..b155a46 100644 --- a/apps/leads/src/lead_requests.rs +++ b/apps/leads/src/lead_requests.rs @@ -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> { 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, diff --git a/apps/payments/src/admin.rs b/apps/payments/src/admin.rs index 8746e15..d9494ad 100644 --- a/apps/payments/src/admin.rs +++ b/apps/payments/src/admin.rs @@ -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, user_name: Option, @@ -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, diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index 68e541d..a27971f 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -84,6 +84,8 @@ struct VerifyPaymentRequest { struct VerifyPaymentResponse { verified: bool, payment_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + reference_number: Option, 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, 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, @@ -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, (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(), diff --git a/apps/users/src/handlers/admin.rs b/apps/users/src/handlers/admin.rs index 5d2db03..0758ac5 100644 --- a/apps/users/src/handlers/admin.rs +++ b/apps/users/src/handlers/admin.rs @@ -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, pub last_name: Option, @@ -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' diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 68865ea..3a1d9e5 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -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, 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, }) } diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index 34a144d..d484f63 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -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 = r.try_get("notes").ok().flatten(); let rejection_reason: Option = r.try_get("rejection_reason").ok().flatten(); let updated_at: Option> = 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() diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index db1ba05..f6a761e 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -29,11 +29,20 @@ pub fn admin_router() -> Router { .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 { + 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, #[serde(rename = "type")] @@ -71,6 +80,7 @@ struct MessageDto { #[derive(sqlx::FromRow)] struct TicketRow { id: Uuid, + reference_number: String, subject: String, description: Option, 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, 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, +} + +async fn ai_reference_lookup( + State(state): State, + headers: HeaderMap, + Path(number): Path, + Query(q): Query, +) -> 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 { + 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 = row.try_get("notes").ok().flatten(); + let rejection_reason: Option = row.try_get("rejection_reason").ok().flatten(); + let created_at: chrono::DateTime = + 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 { + 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 = + 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 { + 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 = + 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 { + 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 = + 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 { + 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 = row.try_get("message").ok().flatten(); + let created_at: chrono::DateTime = + 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(), + })) +} diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 249e948..e839085 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -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()) diff --git a/crates/db/migrations/20260719120000_reference_numbers.down.sql b/crates/db/migrations/20260719120000_reference_numbers.down.sql new file mode 100644 index 0000000..6008698 --- /dev/null +++ b/crates/db/migrations/20260719120000_reference_numbers.down.sql @@ -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; diff --git a/crates/db/migrations/20260719120000_reference_numbers.up.sql b/crates/db/migrations/20260719120000_reference_numbers.up.sql new file mode 100644 index 0000000..92b8e6a --- /dev/null +++ b/crates/db/migrations/20260719120000_reference_numbers.up.sql @@ -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 $$; diff --git a/crates/db/src/models/application.rs b/crates/db/src/models/application.rs index f2d27d7..53d875d 100644 --- a/crates/db/src/models/application.rs +++ b/crates/db/src/models/application.rs @@ -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, diff --git a/crates/db/src/models/lead_request.rs b/crates/db/src/models/lead_request.rs index 3d5660b..86ac1e1 100644 --- a/crates/db/src/models/lead_request.rs +++ b/crates/db/src/models/lead_request.rs @@ -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, pub user_role_profile_id: Option, pub professional_user_id: Option, diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs index 8c65cdd..c912ff5 100644 --- a/crates/db/src/models/user.rs +++ b/crates/db/src/models/user.rs @@ -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, @@ -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 { 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 { 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 { 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 { 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, diff --git a/crates/db/src/models/verification.rs b/crates/db/src/models/verification.rs index 1294fdb..f46f69e 100644 --- a/crates/db/src/models/verification.rs +++ b/crates/db/src/models/verification.rs @@ -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,