use db::conn::sea_orm::*; use db::space::space_invite as invite_entity; use db::space::space_member_roles as member_role_entity; use db::space::spaces as space_entity; use time::OffsetDateTime; use uuid::Uuid; use crate::error::{SpaceError, SpaceResult}; use crate::features::SpaceService; use crate::features::authz; use super::types::{AcceptInviteCmd, CreateInviteCmd, SpaceInvitePage, SpaceInviteView}; async fn owner_space( svc: &SpaceService, caller: Uuid, space_id: Uuid, ) -> SpaceResult { let db = svc.db.reader(); let space = space_entity::Entity::find_by_id(space_id) .one(&db) .await? .ok_or_else(|| SpaceError::NotFound(format!("space {space_id}")))?; if space.owner_id != caller { return Err(SpaceError::Forbidden( "only the owner can manage invites".to_string(), )); } Ok(space) } fn parse_ts(s: Option<&str>) -> SpaceResult> { match s { None | Some("") => Ok(None), Some(v) => OffsetDateTime::parse(v, &time::format_description::well_known::Rfc3339) .map(Some) .map_err(|_| SpaceError::BadRequest("invalid timestamp".to_string())), } } pub async fn create_invite( svc: &SpaceService, caller: Uuid, space_id: Uuid, cmd: CreateInviteCmd, ) -> SpaceResult { owner_space(svc, caller, space_id).await?; if cmd.user_id.is_none() && cmd.email.is_none() { return Err(SpaceError::BadRequest( "either user_id or email must be provided".to_string(), )); } if let Some(user_id) = cmd.user_id { let db = svc.db.reader(); let existing = invite_entity::Entity::find() .filter(invite_entity::Column::SpaceId.eq(space_id)) .filter(invite_entity::Column::UserId.eq(user_id)) .filter(invite_entity::Column::AcceptedAt.is_null()) .filter(invite_entity::Column::RevokedAt.is_null()) .one(&db) .await?; if existing.is_some() { return Err(SpaceError::Conflict(format!( "an active invite already exists for user {user_id} in space {space_id}" ))); } } let email = cmd.email.unwrap_or_default(); if email.chars().count() > svc.config.max_invite_email_len { return Err(SpaceError::BadRequest(format!( "email exceeds {} characters", svc.config.max_invite_email_len ))); } let expires_at = parse_ts(cmd.expires_at.as_deref())?; let db = svc.db.writer(); let now = OffsetDateTime::now_utc(); let id = Uuid::new_v4(); let accept_code = format!("{}-{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let role_ids = cmd .role_ids .iter() .map(|r| r.to_string()) .collect::>() .join(","); let invite: invite_entity::ActiveModel = invite_entity::ActiveModel::builder() .set_id(id) .set_space_id(space_id) .set_user_id(cmd.user_id.unwrap_or_default()) .set_email(email) .set_role_ids(role_ids) .set_accept_code(accept_code) .set_created_at(now) .set_expires_at(expires_at) .set_accepted_at(None) .set_revoked_at(None) .into(); let saved = invite.insert(&db).await?; Ok(SpaceInviteView::from(saved)) } pub async fn revoke_invite( svc: &SpaceService, caller: Uuid, space_id: Uuid, invite_id: Uuid, ) -> SpaceResult { owner_space(svc, caller, space_id).await?; let db = svc.db.writer(); let invite = invite_entity::Entity::find_by_id(invite_id) .one(&db) .await? .ok_or_else(|| SpaceError::NotFound(format!("invite {invite_id}")))?; if invite.space_id != space_id { return Err(SpaceError::NotFound(format!("invite {invite_id}"))); } let mut active: invite_entity::ActiveModel = invite.into(); active.revoked_at = Set(Some(OffsetDateTime::now_utc())); let saved = active.update(&db).await?; Ok(SpaceInviteView::from(saved)) } pub async fn accept_invite( svc: &SpaceService, caller: Uuid, cmd: AcceptInviteCmd, ) -> SpaceResult<()> { let db = svc.db.writer(); let invite = invite_entity::Entity::find() .filter(invite_entity::Column::AcceptCode.eq(&cmd.accept_code)) .one(&db) .await? .ok_or_else(|| SpaceError::NotFound("invite".to_string()))?; if invite.user_id != caller { return Err(SpaceError::Forbidden( "this invite does not belong to you".to_string(), )); } if invite.revoked_at.is_some() { return Err(SpaceError::Conflict("invite has been revoked".to_string())); } if invite.accepted_at.is_some() { return Err(SpaceError::Conflict("invite already accepted".to_string())); } if let Some(exp) = invite.expires_at && OffsetDateTime::now_utc() > exp { return Err(SpaceError::Conflict("invite has expired".to_string())); } let role_ids = invite .role_ids .split(',') .filter_map(|s| Uuid::parse_str(s).ok()) .collect::>(); let now = OffsetDateTime::now_utc(); for role_id in &role_ids { let mr: member_role_entity::ActiveModel = member_role_entity::ActiveModel::builder() .set_space_id(invite.space_id) .set_user_id(caller) .set_role_id(*role_id) .set_created_at(now) .into(); mr.insert(&db).await?; } let mut active: invite_entity::ActiveModel = invite.into(); active.accepted_at = Set(Some(now)); active.update(&db).await?; Ok(()) } pub async fn list_invites( svc: &SpaceService, caller: Uuid, space_id: Uuid, limit: u64, offset: u64, ) -> SpaceResult { authz::require_perm(svc, caller, space_id, authz::MANAGE_INVITES).await?; let db = svc.db.reader(); let limit = std::cmp::max(std::cmp::min(limit, svc.config.max_pagination), 1); let items = invite_entity::Entity::find() .filter(invite_entity::Column::SpaceId.eq(space_id)) .order_by_desc(invite_entity::Column::CreatedAt) .limit(Some(limit as u64)) .offset(Some(offset)) .all(&db) .await?; let total = invite_entity::Entity::find() .filter(invite_entity::Column::SpaceId.eq(space_id)) .count(&db) .await?; Ok(SpaceInvitePage { items: items.into_iter().map(SpaceInviteView::from).collect(), total: total as i64, }) }