feat(members): add the ability to delete/re-enable members

This is effectively a soft-remove feature. Permanent deletion isn't allowed for moderation purposes.
This commit is contained in:
Suya1671 2025-06-23 15:27:13 +02:00
parent 730058973c
commit b784ed1634
No known key found for this signature in database
8 changed files with 257 additions and 72 deletions

1
Cargo.lock generated
View file

@ -2466,6 +2466,7 @@ dependencies = [
"displaydoc",
"dotenvy 0.15.7 (git+https://github.com/allan2/dotenvy)",
"error-stack",
"futures",
"http-body-util",
"libsqlite3-sys",
"menv",

View file

@ -46,6 +46,7 @@ url = "2.5.4"
serde_json = "1.0.140"
tower-http = { version = "0.6.6", features = ["trace"] }
derive_more = { version = "2.0.1", features = ["from"] }
futures = "0.3.31"
[features]
encrypt = ["libsqlite3-sys/bundled-sqlcipher"]

View file

@ -0,0 +1,2 @@
-- Add migration script here
ALTER TABLE members ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;

View file

@ -0,0 +1,11 @@
-- Add migration script here
CREATE TRIGGER check_active_member_is_enabled
BEFORE UPDATE OF currently_fronting_member_id ON systems
FOR EACH ROW
BEGIN
SELECT
RAISE(ABORT, 'Cannot update currently_fronting_member_id to a disabled member')
WHERE EXISTS (
SELECT 1 FROM members WHERE id = NEW.currently_fronting_member_id AND enabled = FALSE
);
END;

View file

@ -1,6 +1,7 @@
use std::sync::Arc;
use error_stack::{Result, ResultExt};
use error_stack::{Result, ResultExt, report};
use futures::TryStreamExt;
use slack_morphism::prelude::*;
use tracing::{debug, info, trace};
@ -9,6 +10,7 @@ use crate::{
models::{
self,
member::{self, MemberRef, View},
trust::Untrusted,
user,
},
};
@ -30,20 +32,29 @@ use crate::{
pub enum Member {
/// Adds a new member to your system. Expect a popup to fill in the member info!
Add,
/// Deletes a member from your system.
/// Disables/Deletes a member from your system.
///
/// This doesn't actually "delete" the member entirely, nor does it delete messages sent by this member.
/// Rather, the member is disabled and cannot be accessed. This is for moderation purposes.
/// If you wish for the member to be re-enabled, [TO-DO: Implement re-enable functionality]
Delete {
/// If you wish for the member to be re-enabled, you can use the `/members enable` command.
///
/// Disabling a member also prevents them from being accessed via their aliases or triggers.
Disable {
/// The member to delete
member: MemberRef,
},
/// Enables a member from your system.
///
/// This will re-enable the member and allow them to be accessed again.
Enable {
/// The member to enable
member: member::Id<Untrusted>,
},
/// Gets info about a member
///
/// This will display information about the member, including their name, pronouns, and other details.
Info {
/// The member to get info about.
/// The member to get info about. You must use the member's ID, which you can get from /members list.
member_id: MemberRef,
},
/// Lists all members in a system
@ -100,12 +111,8 @@ impl Member {
let session = client.open_session(token);
Self::create_member(event, session).await
}
Self::Delete { member } => {
debug!(member_id = ?member, "Delete member command not implemented");
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Working on it".into()),
))
}
Self::Disable { member } => Self::disable(event, &state, member).await,
Self::Enable { member } => Self::enable(event, &state, member).await,
Self::Info { member_id } => Self::member_info(event, &state, member_id).await,
Self::Edit { member_id } => {
Self::edit_member(event, client.open_session(&BOT_TOKEN), &state, member_id).await
@ -135,13 +142,27 @@ impl Member {
} else {
debug!(requested_member_id = ?&member_ref, "Validating member ID");
fetch_member!(member_ref.as_ref().unwrap(), user_state, system_id => member_id);
if !member_id
.enabled(&user_state.db)
.await
.change_context(CommandError::Sqlx)?
{
debug!("Member is disabled");
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("The member you're trying to switch to is disabled! Either re-enable them or choose another member.".into()),
));
}
Some(member_id)
};
debug!(target_member_id = ?new_active_member_id, "Changing active member");
let new_member = system_id
.change_active_member(new_active_member_id, &user_state.db)
.change_fronting_member(new_active_member_id, &user_state.db)
.await;
let response = match new_member {
@ -205,48 +226,131 @@ impl Member {
fields!(system_id = %system.id);
let members = system
.members(&user_state.db)
.await
.change_context(CommandError::Sqlx)?;
debug!(member_count = members.len(), "Retrieved system members");
let member_blocks = members
let member_blocks = sqlx::query!(
"
SELECT
members.id,
display_name,
full_name,
enabled,
GROUP_CONCAT(aliases.alias, ', ') as aliases
FROM
members
JOIN
aliases ON members.id = aliases.member_id
WHERE
members.system_id = $1
GROUP BY members.id
",
system.id
)
.fetch(&user_state.db)
.map_ok(|member| {
let fields = [
Some(md!("*Member ID*: {}", member.id)),
Some(md!("*Display Name*: {}", member.display_name)),
Some(md!("*Aliases: {}", member.aliases)),
Some(md!("*Disabled*")).filter(|_| !member.enabled),
]
.into_iter()
.map(|member| {
let fields = [
Some(md!("Member ID: {}", member.id)),
Some(md!("Display Name: {}", member.display_name)),
member.title.as_ref().map(|title| md!("Title: {}", title)),
member
.pronouns
.as_ref()
.map(|pronouns| md!("Pronouns: {}", pronouns)),
member
.name_pronunciation
.as_ref()
.map(|name_pronunciation| {
md!("Name Pronunciation: {}", name_pronunciation)
}),
Some(md!("Created At: {}", member.created_at)),
]
.into_iter()
.flatten()
.collect();
SlackSectionBlock::new()
.with_text(md!("Name: {}", member.full_name))
.with_fields(fields)
})
.map(Into::into)
.flatten()
.collect();
SlackSectionBlock::new()
.with_text(md!("*{}*", member.full_name))
.with_fields(fields)
})
.map_ok(Into::into)
.map_err(|err| report!(err).change_context(CommandError::Sqlx))
.try_collect()
.await?;
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_blocks(member_blocks),
))
}
#[tracing::instrument(skip(event, state), fields(user_id = %event.user_id, system_id, member_id))]
async fn disable(
event: SlackCommandEvent,
state: &SlackClientEventsUserState,
member_ref: MemberRef,
) -> Result<SlackCommandEventResponse, CommandError> {
trace!("Running member disable command");
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
fetch_system!(event, user_state => system_id);
fetch_member!(member_ref, user_state, system_id => member_id);
if !member_id
.enabled(&user_state.db)
.await
.change_context(CommandError::Sqlx)?
{
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Member is already disabled".into()),
));
}
let system_fronting_member_id = system_id
.currently_fronting_member_id(&user_state.db)
.await
.change_context(CommandError::Sqlx)?;
if system_fronting_member_id.is_some_and(|id| id == member_id) {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Cannot disable the currently fronting member. You can use `/members switch` to switch to another member.".into()),
));
}
member_id
.set_enabled(false, &user_state.db)
.await
.change_context(CommandError::Sqlx)?;
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Member disabled".into()),
))
}
#[tracing::instrument(skip(event, state), fields(user_id = %event.user_id, system_id, member_id))]
async fn enable(
event: SlackCommandEvent,
state: &SlackClientEventsUserState,
member: member::Id<Untrusted>,
) -> Result<SlackCommandEventResponse, CommandError> {
trace!("Running member enable command");
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
fetch_system!(event, user_state => system_id);
fetch_member!(member, user_state, system_id => member_id);
if member_id
.enabled(&user_state.db)
.await
.change_context(CommandError::Sqlx)?
{
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Member is already enabled".into()),
));
}
member_id
.set_enabled(true, &user_state.db)
.await
.change_context(CommandError::Sqlx)?;
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Member enabled".into()),
))
}
#[tracing::instrument(skip(event, state), fields(user_id = %event.user_id, system_id, member_id))]
async fn member_info(
event: SlackCommandEvent,
@ -268,29 +372,47 @@ impl Member {
debug!("Member found");
let fields = [
Some(md!("Member ID: {}", member.id)),
Some(md!("Display Name: {}", member.display_name)),
member.title.as_ref().map(|title| md!("Title: {}", title)),
member
.pronouns
.as_ref()
.map(|pronouns| md!("Pronouns: {}", pronouns)),
member
.name_pronunciation
.as_ref()
.map(|name_pronunciation| md!("Name Pronunciation: {}", name_pronunciation)),
]
.into_iter()
.flatten()
.collect();
if !member.enabled {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(format!(
"Member {} is not enabled. You can use `/member enable {}` to enable them.",
member.full_name, member.id
)),
));
}
let block = SlackSectionBlock::new()
.with_text(md!("Name: {}", member.full_name))
.with_fields(fields);
let system_fronting_member_id = system_id
.currently_fronting_member_id(&user_state.db)
.await
.change_context(CommandError::Sqlx)?;
let blocks = slack_blocks![
some_into(SlackHeaderBlock::new(member.full_name.into())),
some_into(SlackDividerBlock::new()),
some_into(
SlackSectionBlock::new()
.with_text(md!(
"*{}*\n{}{}",
member.display_name,
member.pronouns.unwrap_or_default(),
member
.name_pronunciation
.map(|pronunciation| format!(" - {pronunciation}"))
.unwrap_or_default()
))
.opt_accessory(member.profile_picture_url.and_then(|url| Some(
SlackSectionBlockElement::Image(SlackBlockImageElement::new(
url.parse().ok()?,
"Profile picture".into()
))
)))
),
optionally_into(system_fronting_member_id.is_some_and(|id| id == member.id) => SlackSectionBlock::new().with_text(md!("*Fronting*")))
// TO-DO: fields
];
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_blocks(vec![block.into()]),
SlackMessageContent::new().with_blocks(blocks),
))
}

View file

@ -104,6 +104,30 @@ impl Id<Trusted> {
pub async fn fetch(self, db: &SqlitePool) -> Result<Member, sqlx::Error> {
Member::fetch_by_id(self, db).await
}
#[tracing::instrument(skip(db))]
pub async fn enabled(self, db: &SqlitePool) -> Result<bool, sqlx::Error> {
sqlx::query!("SELECT enabled FROM members WHERE id = $1", self)
.fetch_one(db)
.await
.attach_printable("Failed to fetch member enabled status")
.map(|res| res.enabled)
}
pub async fn set_enabled(
self,
enabled: bool,
db: &SqlitePool,
) -> Result<SqliteQueryResult, sqlx::Error> {
sqlx::query!(
"UPDATE members SET enabled = $1 WHERE id = $2",
enabled,
self
)
.execute(db)
.await
.attach_printable("Failed to update member enabled status")
}
}
#[derive(Debug, Clone)]
@ -162,6 +186,8 @@ pub struct Member {
pub name_pronunciation: Option<String>,
pub name_recording_url: Option<String>,
pub created_at: time::PrimitiveDateTime,
/// A deleted member is effectively a disabled member. They exist in the database, but you cannot interact with them in many ways.
pub enabled: bool,
}
impl Member {
@ -181,6 +207,7 @@ impl Member {
pronouns,
name_pronunciation,
name_recording_url,
enabled,
created_at as "created_at: time::PrimitiveDateTime"
FROM members
WHERE id = $1

View file

@ -44,7 +44,7 @@ impl Id<Trusted> {
}
#[tracing::instrument(skip(db))]
pub async fn change_active_member(
pub async fn change_fronting_member(
self,
new_active_member_id: Option<member::Id<Trusted>>,
db: &SqlitePool,
@ -82,6 +82,25 @@ impl Id<Trusted> {
Ok(new_active_member)
}
#[tracing::instrument(skip(db))]
pub async fn currently_fronting_member_id(
&self,
db: &SqlitePool,
) -> Result<Option<member::Id<Trusted>>, sqlx::Error> {
sqlx::query!(
r#"
SELECT currently_fronting_member_id as "id: member::Id<Trusted>"
FROM systems
WHERE id = $1
"#,
self.id
)
.fetch_one(db)
.await
.attach_printable("Failed to fetch system currently fronting member id")
.map(|row| row.id)
}
#[tracing::instrument(skip(db))]
pub async fn fetch(self, db: &SqlitePool) -> Result<System, sqlx::Error> {
sqlx::query_as!(
@ -196,7 +215,7 @@ impl System {
) -> Result<Option<Member>, sqlx::Error> {
let new_active_member = self
.id
.change_active_member(new_fronting_member_id, db)
.change_fronting_member(new_fronting_member_id, db)
.await?;
self.currently_fronting_member_id = new_fronting_member_id;
@ -217,6 +236,7 @@ impl System {
pronouns,
name_pronunciation,
name_recording_url,
enabled,
created_at as "created_at: time::PrimitiveDateTime"
FROM
members
@ -250,8 +270,9 @@ impl System {
triggers ON members.id = triggers.member_id
WHERE
-- See trigger.rs file for all types and names
(triggers.typ = 0 AND $1 LIKE '%' || triggers.text) OR
(triggers.typ = 1 AND $1 LIKE triggers.text || '%')
members.enabled = TRUE AND
((triggers.typ = 0 AND $1 LIKE '%' || triggers.text) OR
(triggers.typ = 1 AND $1 LIKE triggers.text || '%'))
"#,
message
)

View file

@ -21,13 +21,13 @@ use std::fmt::Debug;
pub trait Trustability: Send + Sync + Debug {}
/// A trusted/valid ID
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Trusted;
impl Trustability for Trusted {}
/// An untrusted ID
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Untrusted;
impl Trustability for Untrusted {}