Skip to content
Clone
a06673b
188 lines 6.8 KB Rust
use db::conn::sea_orm::{
ActiveModelTrait, EntityTrait, QueryFilter, Set, TransactionTrait,
};
use uuid::Uuid;
use crate::error::{UserError, UserResult};
use crate::features::UserService;
use super::types::{
AddEmailCmd, AddEmailResp, ConfirmEmailChangeCmd, EmailChangeTicketStatus, EmailChangeTicketView,
EmailView, SetPrimaryEmailResp,
};
const EMAIL_MAX_LEN: usize = 254;
pub async fn list(svc: &UserService, caller: Uuid) -> UserResult<Vec<EmailView>> {
let db = svc.db.reader();
let rows = db::user::user_emails::Entity::find()
.filter(db::user::user_emails::COLUMN.user_id.eq(caller))
.filter(db::user::user_emails::COLUMN.deleted_at.is_null())
.all(&db)
.await?;
Ok(rows.into_iter().map(EmailView::from).collect())
}
pub async fn add(svc: &UserService, caller: Uuid, cmd: AddEmailCmd) -> UserResult<AddEmailResp> {
let email = cmd.email.trim().to_lowercase();
if email.is_empty() || email.len() > EMAIL_MAX_LEN || !email.contains('@') {
return Err(UserError::BadRequest(format!(
"invalid email (length 1..={EMAIL_MAX_LEN})"
)));
}
let writer = svc.db.writer();
let now = time::OffsetDateTime::now_utc();
let active: db::user::user_emails::ActiveModel = db::user::user_emails::ActiveModel::builder()
.set_id(Uuid::new_v4())
.set_user_id(caller)
.set_email(email.clone())
.set_lower_email(email)
.set_is_activated(false)
.set_is_primary(false)
.set_created_at(now)
.set_updated_at(now)
.set_activated_at(None)
.set_deleted_at(None)
.into();
let saved = active.insert(&writer).await?;
Ok(AddEmailResp { email_id: saved.id })
}
pub async fn set_primary(
svc: &UserService,
caller: Uuid,
email_id: Uuid,
) -> UserResult<SetPrimaryEmailResp> {
let writer = svc.db.writer();
let txn = writer.begin().await?;
let target = db::user::user_emails::Entity::find_by_id(email_id)
.one(&txn)
.await?
.ok_or_else(|| UserError::NotFound(format!("email {email_id}")))?;
if target.user_id != caller {
return Err(UserError::Forbidden("cannot set other user's primary".into()));
}
if !target.is_activated {
return Err(UserError::BadRequest("email not yet activated".into()));
}
let others = db::user::user_emails::Entity::find()
.filter(db::user::user_emails::COLUMN.user_id.eq(caller))
.filter(db::user::user_emails::COLUMN.is_primary.eq(true))
.all(&txn)
.await?;
for other in others {
let mut am: db::user::user_emails::ActiveModel = other.into();
am.is_primary = Set(false);
am.updated_at = Set(time::OffsetDateTime::now_utc());
am.update(&txn).await?;
}
let mut am: db::user::user_emails::ActiveModel = target.into();
am.is_primary = Set(true);
am.updated_at = Set(time::OffsetDateTime::now_utc());
let saved = am.update(&txn).await?;
txn.commit().await?;
Ok(SetPrimaryEmailResp {
email: EmailView::from(saved),
})
}
pub async fn soft_delete(svc: &UserService, caller: Uuid, email_id: Uuid) -> UserResult<()> {
let writer = svc.db.writer();
let row = db::user::user_emails::Entity::find_by_id(email_id)
.one(&writer)
.await?
.ok_or_else(|| UserError::NotFound(format!("email {email_id}")))?;
if row.user_id != caller {
return Err(UserError::Forbidden("cannot delete other user's email".into()));
}
if row.is_primary && row.is_activated {
return Err(UserError::BadRequest(
"cannot delete primary activated email".into(),
));
}
let mut am: db::user::user_emails::ActiveModel = row.into();
am.deleted_at = Set(Some(time::OffsetDateTime::now_utc()));
am.updated_at = Set(time::OffsetDateTime::now_utc());
am.update(&writer).await?;
Ok(())
}
pub async fn request_change(
svc: &UserService,
caller: Uuid,
new_email: String,
) -> UserResult<EmailChangeTicketView> {
let new_email = new_email.trim().to_lowercase();
if new_email.is_empty() || new_email.len() > EMAIL_MAX_LEN || !new_email.contains('@') {
return Err(UserError::BadRequest(format!(
"invalid email (length 1..={EMAIL_MAX_LEN})"
)));
}
let writer = svc.db.writer();
let now = time::OffsetDateTime::now_utc();
let active: db::user::user_email_change_tickets::ActiveModel =
db::user::user_email_change_tickets::ActiveModel::builder()
.set_id(Uuid::new_v4())
.set_user_id(caller)
.set_token_hash(format!("placeholder:{}", Uuid::new_v4()))
.set_new_email(new_email)
.set_original_email(None)
.set_status(EmailChangeTicketStatus::Pending)
.set_created_at(now)
.set_expires_at(now + time::Duration::hours(24))
.set_used_at(None)
.into();
let saved = active.insert(&writer).await?;
Ok(EmailChangeTicketView::from(saved))
}
pub async fn confirm_change(
svc: &UserService,
caller: Uuid,
cmd: ConfirmEmailChangeCmd,
) -> UserResult<EmailChangeTicketView> {
let writer = svc.db.writer();
let row = db::user::user_email_change_tickets::Entity::find_by_id(cmd.ticket_id)
.one(&writer)
.await?
.ok_or_else(|| UserError::NotFound(format!("ticket {}", cmd.ticket_id)))?;
if row.user_id != caller {
return Err(UserError::Forbidden("not your ticket".into()));
}
let target = EmailChangeTicketStatus::from(row.status);
if !matches!(
target,
EmailChangeTicketStatus::Pending | EmailChangeTicketStatus::Verified
) {
return Err(UserError::BadRequest("ticket not actionable".into()));
}
let mut am: db::user::user_email_change_tickets::ActiveModel = row.into();
let next = match target {
EmailChangeTicketStatus::Pending => EmailChangeTicketStatus::Verified,
EmailChangeTicketStatus::Verified => EmailChangeTicketStatus::Completed,
_ => unreachable!(),
};
am.status = Set(next.into());
if matches!(next, EmailChangeTicketStatus::Completed) {
am.used_at = Set(Some(time::OffsetDateTime::now_utc()));
}
let saved = am.update(&writer).await?;
Ok(EmailChangeTicketView::from(saved))
}
pub async fn cancel_change(svc: &UserService, caller: Uuid, ticket_id: Uuid) -> UserResult<()> {
let writer = svc.db.writer();
let row = db::user::user_email_change_tickets::Entity::find_by_id(ticket_id)
.one(&writer)
.await?
.ok_or_else(|| UserError::NotFound(format!("ticket {ticket_id}")))?;
if row.user_id != caller {
return Err(UserError::Forbidden("not your ticket".into()));
}
let mut am: db::user::user_email_change_tickets::ActiveModel = row.into();
am.status = Set(EmailChangeTicketStatus::Cancelled.into());
am.update(&writer).await?;
Ok(())
}