use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::auth::uuid_from_bytes;
use crate::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GroupRole {
Owner,
Member,
}
impl GroupRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Member => "member",
}
}
pub fn parse(raw: &str) -> Result<Self, Error> {
match raw {
"owner" => Ok(Self::Owner),
"member" => Ok(Self::Member),
other => Err(Error::BadRequest(format!("unknown role `{other}`"))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum InvitationStatus {
Pending,
Accepted,
Rejected,
}
impl InvitationStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Accepted => "accepted",
Self::Rejected => "rejected",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct GroupView {
pub group_id: Uuid,
pub name: String,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub my_role: GroupRole,
}
#[derive(Debug, Clone, Serialize)]
pub struct GroupMemberView {
pub user_id: Uuid,
pub username: String,
pub legacy_name: String,
pub role: GroupRole,
pub created_at: DateTime<Utc>,
}
pub async fn lookup_role(
db: &SqlitePool,
group_id: Uuid,
user_id: Uuid,
) -> Result<Option<GroupRole>, Error> {
let row: Option<(String,)> =
sqlx::query_as("SELECT role FROM group_memberships WHERE group_id = ?1 AND user_id = ?2")
.bind(group_id.as_bytes().to_vec())
.bind(user_id.as_bytes().to_vec())
.fetch_optional(db)
.await
.map_err(|err| {
tracing::error!("group role lookup failed: {err}");
Error::Database
})?;
row.map(|(role,)| GroupRole::parse(&role)).transpose()
}
pub async fn try_promote_member_to_owner(
db: &SqlitePool,
group_id: Uuid,
user_id: Uuid,
) -> Result<bool, Error> {
let result = sqlx::query(
"UPDATE group_memberships SET role = 'owner' \
WHERE group_id = ?1 AND user_id = ?2 AND role = 'member'",
)
.bind(group_id.as_bytes().to_vec())
.bind(user_id.as_bytes().to_vec())
.execute(db)
.await
.map_err(|err| {
tracing::error!("promote to owner failed: {err}");
Error::Database
})?;
Ok(result.rows_affected() == 1)
}
pub async fn try_self_demote_owner(
db: &SqlitePool,
group_id: Uuid,
user_id: Uuid,
) -> Result<bool, Error> {
let result = sqlx::query(
"UPDATE group_memberships SET role = 'member' \
WHERE group_id = ?1 AND user_id = ?2 AND role = 'owner' \
AND (SELECT COUNT(*) FROM group_memberships \
WHERE group_id = ?1 AND role = 'owner') > 1",
)
.bind(group_id.as_bytes().to_vec())
.bind(user_id.as_bytes().to_vec())
.execute(db)
.await
.map_err(|err| {
tracing::error!("self-demote owner failed: {err}");
Error::Database
})?;
Ok(result.rows_affected() == 1)
}
pub async fn try_remove_non_owner(
db: &SqlitePool,
group_id: Uuid,
user_id: Uuid,
) -> Result<bool, Error> {
let result = sqlx::query(
"DELETE FROM group_memberships \
WHERE group_id = ?1 AND user_id = ?2 AND role = 'member'",
)
.bind(group_id.as_bytes().to_vec())
.bind(user_id.as_bytes().to_vec())
.execute(db)
.await
.map_err(|err| {
tracing::error!("remove non-owner member failed: {err}");
Error::Database
})?;
Ok(result.rows_affected() == 1)
}
pub async fn try_leave(db: &SqlitePool, group_id: Uuid, user_id: Uuid) -> Result<bool, Error> {
let result = sqlx::query(
"DELETE FROM group_memberships \
WHERE group_id = ?1 AND user_id = ?2 \
AND (role <> 'owner' \
OR (SELECT COUNT(*) FROM group_memberships \
WHERE group_id = ?1 AND role = 'owner') > 1)",
)
.bind(group_id.as_bytes().to_vec())
.bind(user_id.as_bytes().to_vec())
.execute(db)
.await
.map_err(|err| {
tracing::error!("leave group failed: {err}");
Error::Database
})?;
Ok(result.rows_affected() == 1)
}
pub async fn require_exists(db: &SqlitePool, group_id: Uuid) -> Result<(), Error> {
let row: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM groups WHERE group_id = ?1")
.bind(group_id.as_bytes().to_vec())
.fetch_optional(db)
.await
.map_err(|err| {
tracing::error!("group existence check failed: {err}");
Error::Database
})?;
if row.is_none() {
return Err(Error::NotFound(format!("group {group_id}")));
}
Ok(())
}
pub async fn create_group(db: &SqlitePool, name: &str, creator: Uuid) -> Result<Uuid, Error> {
let group_id = Uuid::new_v4();
let now = Utc::now();
let mut tx = db.begin().await.map_err(|err| {
tracing::error!("begin tx for group create failed: {err}");
Error::Database
})?;
sqlx::query(
"INSERT INTO groups (group_id, name, created_by, created_at, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?4)",
)
.bind(group_id.as_bytes().to_vec())
.bind(name)
.bind(creator.as_bytes().to_vec())
.bind(now)
.execute(&mut *tx)
.await
.map_err(|err| {
tracing::error!("insert groups row failed: {err}");
Error::Database
})?;
sqlx::query(
"INSERT INTO group_memberships (group_id, user_id, role, created_at) \
VALUES (?1, ?2, 'owner', ?3)",
)
.bind(group_id.as_bytes().to_vec())
.bind(creator.as_bytes().to_vec())
.bind(now)
.execute(&mut *tx)
.await
.map_err(|err| {
tracing::error!("insert owner membership failed: {err}");
Error::Database
})?;
tx.commit().await.map_err(|err| {
tracing::error!("commit group create failed: {err}");
Error::Database
})?;
Ok(group_id)
}
type MemberRow = (Vec<u8>, String, String, String, DateTime<Utc>);
pub async fn list_members(db: &SqlitePool, group_id: Uuid) -> Result<Vec<GroupMemberView>, Error> {
let rows: Vec<MemberRow> = sqlx::query_as(
"SELECT users.user_id, users.username, users.legacy_name, \
group_memberships.role, group_memberships.created_at \
FROM group_memberships \
JOIN users ON users.user_id = group_memberships.user_id \
WHERE group_memberships.group_id = ?1 \
ORDER BY group_memberships.role DESC, users.username ASC",
)
.bind(group_id.as_bytes().to_vec())
.fetch_all(db)
.await
.map_err(|err| {
tracing::error!("list members failed: {err}");
Error::Database
})?;
let mut out = Vec::with_capacity(rows.len());
for (uid_bytes, username, legacy_name, role, created_at) in rows {
let user_id =
uuid_from_bytes(&uid_bytes).ok_or_else(|| Error::BadRequest("bad uuid".to_owned()))?;
out.push(GroupMemberView {
user_id,
username,
legacy_name,
role: GroupRole::parse(&role)?,
created_at,
});
}
Ok(out)
}
type GroupRow = (
Vec<u8>,
String,
Option<Vec<u8>>,
DateTime<Utc>,
DateTime<Utc>,
String,
);
pub async fn list_for_user(db: &SqlitePool, user_id: Uuid) -> Result<Vec<GroupView>, Error> {
let rows: Vec<GroupRow> = sqlx::query_as(
"SELECT groups.group_id, groups.name, groups.created_by, \
groups.created_at, groups.updated_at, group_memberships.role \
FROM groups \
JOIN group_memberships ON group_memberships.group_id = groups.group_id \
WHERE group_memberships.user_id = ?1 \
ORDER BY groups.name ASC",
)
.bind(user_id.as_bytes().to_vec())
.fetch_all(db)
.await
.map_err(|err| {
tracing::error!("list groups for user failed: {err}");
Error::Database
})?;
let mut out = Vec::with_capacity(rows.len());
for (gid_bytes, name, created_by_bytes, created_at, updated_at, role) in rows {
let group_id = uuid_from_bytes(&gid_bytes)
.ok_or_else(|| Error::BadRequest("bad group uuid".to_owned()))?;
let created_by = created_by_bytes
.as_deref()
.map(uuid_from_bytes)
.map(|opt| opt.ok_or_else(|| Error::BadRequest("bad creator uuid".to_owned())))
.transpose()?;
out.push(GroupView {
group_id,
name,
created_by,
created_at,
updated_at,
my_role: GroupRole::parse(&role)?,
});
}
Ok(out)
}
pub async fn get_for_user(
db: &SqlitePool,
group_id: Uuid,
user_id: Uuid,
) -> Result<GroupView, Error> {
type GetGroupRow = (
String,
Option<Vec<u8>>,
DateTime<Utc>,
DateTime<Utc>,
String,
);
let row: Option<GetGroupRow> = sqlx::query_as(
"SELECT groups.name, groups.created_by, groups.created_at, groups.updated_at, \
group_memberships.role \
FROM groups \
JOIN group_memberships ON group_memberships.group_id = groups.group_id \
WHERE groups.group_id = ?1 AND group_memberships.user_id = ?2",
)
.bind(group_id.as_bytes().to_vec())
.bind(user_id.as_bytes().to_vec())
.fetch_optional(db)
.await
.map_err(|err| {
tracing::error!("get group for user failed: {err}");
Error::Database
})?;
let (name, created_by_bytes, created_at, updated_at, role) =
row.ok_or_else(|| Error::NotFound(format!("group {group_id}")))?;
let created_by = created_by_bytes
.as_deref()
.map(uuid_from_bytes)
.map(|opt| opt.ok_or_else(|| Error::BadRequest("bad creator uuid".to_owned())))
.transpose()?;
Ok(GroupView {
group_id,
name,
created_by,
created_at,
updated_at,
my_role: GroupRole::parse(&role)?,
})
}
pub async fn rename_group(db: &SqlitePool, group_id: Uuid, name: &str) -> Result<(), Error> {
let now = Utc::now();
sqlx::query("UPDATE groups SET name = ?1, updated_at = ?2 WHERE group_id = ?3")
.bind(name)
.bind(now)
.bind(group_id.as_bytes().to_vec())
.execute(db)
.await
.map_err(|err| {
tracing::error!("rename group failed: {err}");
Error::Database
})?;
Ok(())
}
pub async fn delete_group(db: &SqlitePool, group_id: Uuid) -> Result<(), Error> {
sqlx::query("DELETE FROM groups WHERE group_id = ?1")
.bind(group_id.as_bytes().to_vec())
.execute(db)
.await
.map_err(|err| {
tracing::error!("delete group failed: {err}");
Error::Database
})?;
Ok(())
}