mirror of
https://github.com/System-End/plura.git
synced 2026-04-19 22:05:08 +00:00
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:
parent
730058973c
commit
b784ed1634
8 changed files with 257 additions and 72 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
2
migrations/20250623132809_add_enabled_field.sql
Normal file
2
migrations/20250623132809_add_enabled_field.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- Add migration script here
|
||||
ALTER TABLE members ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue