Skip to content
Clone
a06673b
195 lines 6.5 KB Rust
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<space_entity::Model> {
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<Option<OffsetDateTime>> {
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<SpaceInviteView> {
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::<Vec<_>>()
.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<SpaceInviteView> {
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::<Vec<_>>();
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<SpaceInvitePage> {
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,
})
}