use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode as ReqwestStatusCode;
use axum::response::{IntoResponse as _, Response};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::{self, CurrentUser, uuid_from_bytes};
use crate::error::Error;
use crate::groups::{self, GroupRole, InvitationStatus};
use crate::rate_limit::{self, RateCategory};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct CreateInvitationRequest {
pub identifier: String,
#[serde(default = "default_target_role")]
pub target_role: String,
}
fn default_target_role() -> String {
"member".to_owned()
}
#[derive(Debug, Clone, Serialize)]
pub struct InvitationView {
pub invitation_id: Uuid,
pub group_id: Uuid,
pub group_name: String,
pub inviter_id: Uuid,
pub inviter_username: String,
pub inviter_legacy_name: String,
pub invitee_id: Uuid,
pub invitee_username: String,
pub invitee_legacy_name: String,
pub target_role: GroupRole,
pub status: InvitationStatus,
pub created_at: DateTime<Utc>,
pub responded_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub struct ListInvitationsResponse {
pub invitations: Vec<InvitationView>,
}
#[derive(Debug, Serialize)]
pub struct InvitationResponse {
pub invitation: InvitationView,
}
pub async fn create(
user: CurrentUser,
State(state): State<AppState>,
Path(group_id): Path<Uuid>,
Json(req): Json<CreateInvitationRequest>,
) -> Result<(ReqwestStatusCode, Json<InvitationResponse>), Error> {
rate_limit::try_acquire(&state.db, RateCategory::InvitationCreate, user.user_id).await?;
require_owner(&state, group_id, user.user_id).await?;
let identifier = req.identifier.trim();
if identifier.is_empty() {
return Err(Error::BadRequest("identifier is required".to_owned()));
}
let target_role = GroupRole::parse(req.target_role.trim())?;
let invitee_row = auth::lookup_user_by_identifier(&state.db, identifier)
.await?
.ok_or_else(|| {
Error::BadRequest("no user matches that identifier".to_owned())
})?;
let (invitee_bytes, invitee_legacy, invitee_username, _password_hash) = invitee_row;
let invitee_id = uuid_from_bytes(&invitee_bytes).ok_or_else(|| {
tracing::error!("invitee user_id blob was the wrong length");
Error::Database
})?;
if invitee_id == user.user_id {
return Err(Error::BadRequest(
"cannot invite yourself to a group you are already in".to_owned(),
));
}
if let Some(existing_role) = groups::lookup_role(&state.db, group_id, invitee_id).await? {
return Err(Error::BadRequest(format!(
"user is already a {} of this group",
existing_role.as_str()
)));
}
let invitation_id = Uuid::new_v4();
let now = Utc::now();
let insert = sqlx::query(
"INSERT INTO group_invitations \
(invitation_id, group_id, invitee_id, inviter_id, target_role, status, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6)",
)
.bind(invitation_id.as_bytes().to_vec())
.bind(group_id.as_bytes().to_vec())
.bind(invitee_id.as_bytes().to_vec())
.bind(user.user_id.as_bytes().to_vec())
.bind(target_role.as_str())
.bind(now)
.execute(&state.db)
.await;
if let Err(err) = insert {
if let sqlx::Error::Database(db_err) = &err
&& db_err.is_unique_violation()
{
return Err(Error::BadRequest(
"there is already a pending invitation for this user".to_owned(),
));
}
tracing::error!("invitation insert failed: {err}");
return Err(Error::Database);
}
let inviter_username = user.username.clone();
let inviter_legacy_name = user.legacy_name.clone();
let group_name = group_name(&state, group_id).await?;
let view = InvitationView {
invitation_id,
group_id,
group_name,
inviter_id: user.user_id,
inviter_username,
inviter_legacy_name,
invitee_id,
invitee_username,
invitee_legacy_name: invitee_legacy,
target_role,
status: InvitationStatus::Pending,
created_at: now,
responded_at: None,
};
Ok((
ReqwestStatusCode::CREATED,
Json(InvitationResponse { invitation: view }),
))
}
pub async fn list_for_group(
user: CurrentUser,
State(state): State<AppState>,
Path(group_id): Path<Uuid>,
) -> Result<Json<ListInvitationsResponse>, Error> {
require_owner(&state, group_id, user.user_id).await?;
let invitations = fetch_invitations(&state, InvitationsFilter::Group(group_id)).await?;
Ok(Json(ListInvitationsResponse { invitations }))
}
pub async fn list_mine(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<ListInvitationsResponse>, Error> {
let invitations =
fetch_invitations(&state, InvitationsFilter::PendingForInvitee(user.user_id)).await?;
Ok(Json(ListInvitationsResponse { invitations }))
}
pub async fn accept(
user: CurrentUser,
State(state): State<AppState>,
Path(invitation_id): Path<Uuid>,
) -> Result<Response, Error> {
let row = fetch_pending_for_invitee(&state, invitation_id, user.user_id).await?;
let (group_id_bytes, target_role) = row;
let now = Utc::now();
let mut tx = state.db.begin().await.map_err(|err| {
tracing::error!("begin accept tx failed: {err}");
Error::Database
})?;
sqlx::query(
"INSERT INTO group_memberships (group_id, user_id, role, created_at) \
VALUES (?1, ?2, ?3, ?4) \
ON CONFLICT(group_id, user_id) DO UPDATE \
SET role = excluded.role \
WHERE excluded.role = 'owner' AND group_memberships.role = 'member'",
)
.bind(&group_id_bytes)
.bind(user.user_id.as_bytes().to_vec())
.bind(target_role.as_str())
.bind(now)
.execute(&mut *tx)
.await
.map_err(|err| {
tracing::error!("insert membership on accept failed: {err}");
Error::Database
})?;
let result = sqlx::query(
"UPDATE group_invitations SET status = 'accepted', responded_at = ?1 \
WHERE invitation_id = ?2 AND status = 'pending'",
)
.bind(now)
.bind(invitation_id.as_bytes().to_vec())
.execute(&mut *tx)
.await
.map_err(|err| {
tracing::error!("update invitation on accept failed: {err}");
Error::Database
})?;
if result.rows_affected() != 1 {
return Err(Error::BadRequest(
"invitation is no longer pending".to_owned(),
));
}
tx.commit().await.map_err(|err| {
tracing::error!("commit accept tx failed: {err}");
Error::Database
})?;
Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
}
pub async fn reject(
user: CurrentUser,
State(state): State<AppState>,
Path(invitation_id): Path<Uuid>,
) -> Result<Response, Error> {
let _row = fetch_pending_for_invitee(&state, invitation_id, user.user_id).await?;
let now = Utc::now();
let result = sqlx::query(
"UPDATE group_invitations SET status = 'rejected', responded_at = ?1 \
WHERE invitation_id = ?2 AND status = 'pending'",
)
.bind(now)
.bind(invitation_id.as_bytes().to_vec())
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("update invitation on reject failed: {err}");
Error::Database
})?;
if result.rows_affected() != 1 {
return Err(Error::BadRequest(
"invitation is no longer pending".to_owned(),
));
}
Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
}
async fn fetch_pending_for_invitee(
state: &AppState,
invitation_id: Uuid,
invitee_id: Uuid,
) -> Result<(Vec<u8>, GroupRole), Error> {
let row: Option<(Vec<u8>, Vec<u8>, String, String)> = sqlx::query_as(
"SELECT group_id, invitee_id, target_role, status FROM group_invitations \
WHERE invitation_id = ?1",
)
.bind(invitation_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("fetch invitation failed: {err}");
Error::Database
})?;
let (group_id_bytes, row_invitee_bytes, target_role, status) =
row.ok_or_else(|| Error::NotFound(format!("invitation {invitation_id}")))?;
let row_invitee = uuid_from_bytes(&row_invitee_bytes).ok_or_else(|| {
tracing::error!("bad invitee uuid in invitation");
Error::Database
})?;
if row_invitee != invitee_id {
return Err(Error::NotFound(format!("invitation {invitation_id}")));
}
if status != "pending" {
return Err(Error::BadRequest(format!("invitation is already {status}")));
}
let target_role = GroupRole::parse(&target_role)?;
Ok((group_id_bytes, target_role))
}
enum InvitationsFilter {
Group(Uuid),
PendingForInvitee(Uuid),
}
async fn fetch_invitations(
state: &AppState,
filter: InvitationsFilter,
) -> Result<Vec<InvitationView>, Error> {
let (sql, blob): (&'static str, Vec<u8>) = match filter {
InvitationsFilter::Group(group_id) => (
"SELECT group_invitations.invitation_id, \
group_invitations.group_id, \
groups.name, \
group_invitations.inviter_id, \
inviter.username, inviter.legacy_name, \
group_invitations.invitee_id, \
invitee.username, invitee.legacy_name, \
group_invitations.target_role, \
group_invitations.status, \
group_invitations.created_at, \
group_invitations.responded_at \
FROM group_invitations \
JOIN groups ON groups.group_id = group_invitations.group_id \
JOIN users AS inviter ON inviter.user_id = group_invitations.inviter_id \
JOIN users AS invitee ON invitee.user_id = group_invitations.invitee_id \
WHERE group_invitations.group_id = ?1 \
ORDER BY group_invitations.created_at DESC",
group_id.as_bytes().to_vec(),
),
InvitationsFilter::PendingForInvitee(user_id) => (
"SELECT group_invitations.invitation_id, \
group_invitations.group_id, \
groups.name, \
group_invitations.inviter_id, \
inviter.username, inviter.legacy_name, \
group_invitations.invitee_id, \
invitee.username, invitee.legacy_name, \
group_invitations.target_role, \
group_invitations.status, \
group_invitations.created_at, \
group_invitations.responded_at \
FROM group_invitations \
JOIN groups ON groups.group_id = group_invitations.group_id \
JOIN users AS inviter ON inviter.user_id = group_invitations.inviter_id \
JOIN users AS invitee ON invitee.user_id = group_invitations.invitee_id \
WHERE group_invitations.invitee_id = ?1 \
AND group_invitations.status = 'pending' \
ORDER BY group_invitations.created_at DESC",
user_id.as_bytes().to_vec(),
),
};
let rows = sqlx::query_as::<
_,
(
Vec<u8>,
Vec<u8>,
String,
Vec<u8>,
String,
String,
Vec<u8>,
String,
String,
String,
String,
DateTime<Utc>,
Option<DateTime<Utc>>,
),
>(sql)
.bind(blob)
.fetch_all(&state.db)
.await
.map_err(|err| {
tracing::error!("fetch invitations failed: {err}");
Error::Database
})?;
let mut out = Vec::with_capacity(rows.len());
for (
invitation_bytes,
group_id_bytes,
group_name,
inviter_bytes,
inviter_username,
inviter_legacy_name,
invitee_bytes,
invitee_username,
invitee_legacy_name,
target_role,
status,
created_at,
responded_at,
) in rows
{
let invitation_id = uuid_from_bytes(&invitation_bytes).ok_or_else(|| {
tracing::error!("bad invitation uuid");
Error::Database
})?;
let group_id = uuid_from_bytes(&group_id_bytes).ok_or_else(|| {
tracing::error!("bad group uuid");
Error::Database
})?;
let inviter_id = uuid_from_bytes(&inviter_bytes).ok_or_else(|| {
tracing::error!("bad inviter uuid");
Error::Database
})?;
let invitee_id = uuid_from_bytes(&invitee_bytes).ok_or_else(|| {
tracing::error!("bad invitee uuid");
Error::Database
})?;
let status = match status.as_str() {
"pending" => InvitationStatus::Pending,
"accepted" => InvitationStatus::Accepted,
"rejected" => InvitationStatus::Rejected,
other => {
return Err(Error::BadRequest(format!(
"unknown invitation status `{other}`"
)));
}
};
out.push(InvitationView {
invitation_id,
group_id,
group_name,
inviter_id,
inviter_username,
inviter_legacy_name,
invitee_id,
invitee_username,
invitee_legacy_name,
target_role: GroupRole::parse(&target_role)?,
status,
created_at,
responded_at,
});
}
Ok(out)
}
async fn group_name(state: &AppState, group_id: Uuid) -> Result<String, Error> {
let row: Option<(String,)> = sqlx::query_as("SELECT name FROM groups WHERE group_id = ?1")
.bind(group_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("group name lookup failed: {err}");
Error::Database
})?;
row.map(|(n,)| n)
.ok_or_else(|| Error::NotFound(format!("group {group_id}")))
}
async fn require_owner(state: &AppState, group_id: Uuid, user_id: Uuid) -> Result<(), Error> {
if groups::lookup_role(&state.db, group_id, user_id).await? == Some(GroupRole::Owner) {
Ok(())
} else {
Err(Error::NotFound(format!("group {group_id}")))
}
}