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
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:
parent
dbb02e54cc
commit
e4c9fc31ee
15 changed files with 564 additions and 29 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
171
crates/db/migrations/20260719120000_reference_numbers.up.sql
Normal file
171
crates/db/migrations/20260719120000_reference_numbers.up.sql
Normal 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 $$;
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue