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> { 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 { 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 { 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 { 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 { 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(()) }