Files
imphnen-backend-service/imphnen-iam/src/auth/infrastructure/persistence/postgres_otp_repository.rs
T
asepharyana c6ed5c5c19 fix(dimentorin): verify-email validates OTP before activating user
- new app_otp_cache table + OtpCache entity (ResourceEnum::OtpCache)
- PostgresOtpRepository upsert/find/delete keyed by email
- register/resend persist otp_hash+expiry after email sent (no orphan OTP)
- verify_email validates via OtpManager::validate_otp_hash, single-use delete
- 8 unit tests pass, e2e verified: wrong OTP 400, correct OTP 200
2026-08-04 23:35:34 +07:00

85 lines
2.4 KiB
Rust

use crate::auth::domain::otp::{OtpCacheRecord, OtpRepository};
use async_trait::async_trait;
use chrono::Utc;
use imphnen_entities::seaorm::common::otp_cache::{
ActiveModel as OtpCacheActiveModel, Column as OtpCacheColumn, Entity as OtpCacheEntity,
};
use imphnen_utils::AppError;
use sea_orm::ActiveValue::Set;
use sea_orm::prelude::*;
use std::sync::Arc;
pub struct PostgresOtpRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresOtpRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl OtpRepository for PostgresOtpRepository {
async fn save(&self, record: OtpCacheRecord) -> Result<(), AppError> {
let now = Utc::now();
let otp_hash = record.otp_hash.clone();
let expires_at = record.expires_at;
let active_model = OtpCacheActiveModel {
id: Set(uuid::Uuid::new_v4()),
email: Set(record.email.clone()),
otp_hash: Set(otp_hash.clone()),
expires_at: Set(expires_at),
created_at: Set(now),
updated_at: Set(now),
};
// Upsert: replace any existing (possibly expired) OTP for the same email.
let existing = OtpCacheEntity::find()
.filter(OtpCacheColumn::Email.eq(record.email.clone()))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if let Some(existing) = existing {
let mut update: OtpCacheActiveModel = existing.into();
update.otp_hash = Set(otp_hash);
update.expires_at = Set(expires_at);
update.updated_at = Set(now);
update
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
} else {
OtpCacheEntity::insert(active_model)
.exec(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
}
Ok(())
}
async fn find_by_email(&self, email: &str) -> Result<OtpCacheRecord, AppError> {
let row = OtpCacheEntity::find()
.filter(OtpCacheColumn::Email.eq(email))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("No OTP issued for this email".into()))?;
Ok(OtpCacheRecord {
email: row.email,
otp_hash: row.otp_hash,
expires_at: row.expires_at,
})
}
async fn delete_by_email(&self, email: &str) -> Result<(), AppError> {
OtpCacheEntity::delete_many()
.filter(OtpCacheColumn::Email.eq(email))
.exec(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}