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>
324 lines
10 KiB
Rust
324 lines
10 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::{FromRow, PgPool};
|
|
use uuid::Uuid;
|
|
|
|
// ── Structs ───────────────────────────────────────────────────────────────────
|
|
|
|
#[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>,
|
|
pub last_name: Option<String>,
|
|
pub email_verified: bool,
|
|
pub phone_verified: bool,
|
|
pub status: String, // ACTIVE, SUSPENDED, BANNED
|
|
pub email_verification_token: Option<String>,
|
|
pub email_verification_expires_at: Option<DateTime<Utc>>,
|
|
pub reset_password_token: Option<String>,
|
|
pub reset_password_expires_at: Option<DateTime<Utc>>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
pub deleted_at: Option<DateTime<Utc>>,
|
|
pub litellm_key: Option<String>,
|
|
}
|
|
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct CreateUserPayload {
|
|
pub first_name: Option<String>,
|
|
pub last_name: Option<String>,
|
|
pub email: String,
|
|
pub password_hash: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
|
pub struct RefreshToken {
|
|
pub id: Uuid,
|
|
pub user_id: Uuid,
|
|
pub token_hash: String,
|
|
pub expires_at: DateTime<Utc>,
|
|
pub revoked: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
// ── Repository ────────────────────────────────────────────────────────────────
|
|
|
|
pub struct UserRepository;
|
|
|
|
impl UserRepository {
|
|
pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result<User, sqlx::Error> {
|
|
let user = sqlx::query_as::<_, User>(
|
|
r#"
|
|
INSERT INTO users (first_name, last_name, email, password_hash, email_verified, phone_verified)
|
|
VALUES ($1, $2, $3, $4, false, false)
|
|
RETURNING
|
|
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,
|
|
created_at, updated_at, deleted_at, litellm_key
|
|
"#,
|
|
)
|
|
.bind(&payload.first_name)
|
|
.bind(&payload.last_name)
|
|
.bind(payload.email.to_lowercase())
|
|
.bind(payload.password_hash)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
|
|
Ok(user)
|
|
}
|
|
|
|
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<User, sqlx::Error> {
|
|
sqlx::query_as::<_, User>(
|
|
r#"
|
|
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,
|
|
created_at, updated_at, deleted_at, litellm_key
|
|
FROM users
|
|
WHERE email = $1 AND deleted_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(email.to_lowercase())
|
|
.fetch_one(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<User, sqlx::Error> {
|
|
sqlx::query_as::<_, User>(
|
|
r#"
|
|
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,
|
|
created_at, updated_at, deleted_at, litellm_key
|
|
FROM users
|
|
WHERE id = $1 AND deleted_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(id)
|
|
.fetch_one(pool)
|
|
.await
|
|
}
|
|
|
|
/// Returns all approved role codes for a user, most recently assigned first (e.g. ["VIDEO_EDITOR", "JOB_SEEKER"])
|
|
pub async fn get_user_role_keys(pool: &PgPool, user_id: Uuid) -> Result<Vec<String>, sqlx::Error> {
|
|
let rows = sqlx::query_scalar::<_, String>(
|
|
r#"
|
|
SELECT r.key
|
|
FROM user_role_assignments ur
|
|
JOIN roles r ON ur.role_id = r.id
|
|
WHERE ur.user_id = $1 AND ur.status = 'APPROVED'
|
|
ORDER BY ur.created_at DESC
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
Ok(rows)
|
|
}
|
|
|
|
pub async fn set_email_verification_token(
|
|
|
|
pool: &PgPool,
|
|
user_id: Uuid,
|
|
token: &str,
|
|
expires_at: DateTime<Utc>,
|
|
) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE users
|
|
SET email_verification_token = $1, email_verification_expires_at = $2, updated_at = NOW()
|
|
WHERE id = $3
|
|
"#,
|
|
)
|
|
.bind(token)
|
|
.bind(expires_at)
|
|
.bind(user_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_by_verification_token(pool: &PgPool, token: &str) -> Result<User, sqlx::Error> {
|
|
sqlx::query_as::<_, User>(
|
|
r#"
|
|
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,
|
|
created_at, updated_at, deleted_at, litellm_key
|
|
FROM users
|
|
WHERE email_verification_token = $1 AND deleted_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(token)
|
|
.fetch_one(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn set_email_verified(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
"UPDATE users SET email_verified = true, email_verification_token = NULL, email_verification_expires_at = NULL, updated_at = NOW() WHERE id = $1",
|
|
)
|
|
.bind(user_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn set_reset_token(
|
|
pool: &PgPool,
|
|
user_id: Uuid,
|
|
token: &str,
|
|
expires_at: DateTime<Utc>,
|
|
) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE users
|
|
SET reset_password_token = $1, reset_password_expires_at = $2, updated_at = NOW()
|
|
WHERE id = $3
|
|
"#,
|
|
)
|
|
.bind(token)
|
|
.bind(expires_at)
|
|
.bind(user_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_by_reset_token(pool: &PgPool, token: &str) -> Result<User, sqlx::Error> {
|
|
sqlx::query_as::<_, User>(
|
|
r#"
|
|
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,
|
|
created_at, updated_at, deleted_at, litellm_key
|
|
FROM users
|
|
WHERE reset_password_token = $1 AND deleted_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(token)
|
|
.fetch_one(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn clear_reset_token(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
"UPDATE users SET reset_password_token = NULL, reset_password_expires_at = NULL, updated_at = NOW() WHERE id = $1",
|
|
)
|
|
.bind(user_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn update_password(pool: &PgPool, user_id: Uuid, password_hash: &str) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
"UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2",
|
|
)
|
|
.bind(password_hash)
|
|
.bind(user_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn update_status(pool: &PgPool, user_id: Uuid, status: &str) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
|
|
)
|
|
.bind(status)
|
|
.bind(user_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn set_litellm_key(pool: &PgPool, user_id: Uuid, key: &str) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
"UPDATE users SET litellm_key = $1, updated_at = NOW() WHERE id = $2",
|
|
)
|
|
.bind(key)
|
|
.bind(user_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_litellm_key(pool: &PgPool, user_id: Uuid) -> Result<Option<String>, sqlx::Error> {
|
|
sqlx::query_scalar::<_, Option<String>>(
|
|
"SELECT litellm_key FROM users WHERE id = $1 AND deleted_at IS NULL",
|
|
)
|
|
.bind(user_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn store_refresh_token(
|
|
pool: &PgPool,
|
|
user_id: Uuid,
|
|
token_hash: &str,
|
|
expires_at: DateTime<Utc>,
|
|
) -> Result<RefreshToken, sqlx::Error> {
|
|
sqlx::query_as::<_, RefreshToken>(
|
|
r#"
|
|
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, user_id, token_hash, expires_at, revoked, created_at
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(token_hash)
|
|
.bind(expires_at)
|
|
.fetch_one(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_valid_refresh_token(
|
|
pool: &PgPool,
|
|
token_hash: &str,
|
|
) -> Result<RefreshToken, sqlx::Error> {
|
|
sqlx::query_as::<_, RefreshToken>(
|
|
r#"
|
|
SELECT id, user_id, token_hash, expires_at, revoked, created_at
|
|
FROM refresh_tokens
|
|
WHERE token_hash = $1
|
|
AND revoked = false
|
|
AND expires_at > NOW()
|
|
"#,
|
|
)
|
|
.bind(token_hash)
|
|
.fetch_one(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn revoke_refresh_token(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
"UPDATE refresh_tokens SET revoked = true WHERE token_hash = $1",
|
|
)
|
|
.bind(token_hash)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn revoke_all_for_user(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> {
|
|
sqlx::query(
|
|
"UPDATE refresh_tokens SET revoked = true WHERE user_id = $1",
|
|
)
|
|
.bind(user_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|