diff --git a/src/commands/apply_leave.rs b/src/commands/apply_leave.rs new file mode 100755 index 0000000..f9d6dad --- /dev/null +++ b/src/commands/apply_leave.rs @@ -0,0 +1,75 @@ +use crate::{Data, Error}; +use chrono::{Local, NaiveDate}; +use poise::serenity_prelude as serenity; +use serenity::all::{CreateEmbed, CreateMessage}; + +#[poise::command(slash_command)] +pub async fn apply_leave( + ctx: poise::Context<'_, Data, Error>, + #[description = "Start date (YYYY-MM-DD)"] start_date: NaiveDate, + #[description = "Duration in days"] duration: Option, + reason: String, +) -> Result<(), Error> { + let discord_id = ctx.author().id.get().to_string(); + + ctx.defer().await?; + + let duration = duration.unwrap_or(1); + + let today = Local::now().date_naive(); + + if start_date < today { + ctx.say("❌ Leave start date cannot be in the past.") + .await?; + return Ok(()); + } + + let embed = if duration == 1 { + CreateEmbed::new() + .title("πŸ“ Leave Request") + .description(format!( + "**User:** <@{}>\n\ + **Start Date:** {}\n\ + **Duration:** {} day\n\ + **Reason:** {}\n\n\ + **The Leave Is Approved by Bot** + ", + discord_id, start_date, duration, reason, + )) + } else { + CreateEmbed::new() + .title("πŸ“ Leave Request") + .description(format!( + "**User:** <@{}>\n\ + **Start Date:** {}\n\ + **Duration:** {} days\n\ + **Reason:** {}\n\n\ + React with βœ… to approve.", + discord_id, start_date, duration, reason, + )) + }; + + let message = ctx + .channel_id() + .send_message(ctx, CreateMessage::new().embed(embed)) + .await?; + + let message_id = message.id.get(); + + ctx.data() + .graphql_client + .apply_leave(&discord_id, &message_id, start_date, duration, &reason) + .await?; + if duration != 1 { + message + .react(ctx, serenity::ReactionType::Unicode("βœ…".into())) + .await?; + + message + .react(ctx, serenity::ReactionType::Unicode("❌".into())) + .await?; + } + ctx.say("Leave request submitted.").await?; + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs old mode 100644 new mode 100755 index 12daea8..38ca9f9 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,8 +1,12 @@ +mod apply_leave; mod random; mod set_log_level; +mod summary; +use crate::commands::apply_leave::apply_leave; use crate::commands::random::random; use crate::commands::set_log_level::set_log_level; +use crate::commands::summary::member_summary; use serenity::all::RoleId; use tracing::{debug, instrument}; @@ -33,7 +37,7 @@ async fn amdctl(ctx: Context<'_>) -> Result<(), Error> { /// Returns a vector containg [Poise Commands][`poise::Command`] pub fn get_commands() -> Vec> { - let commands = vec![amdctl(), set_log_level(), random()]; + let commands = vec![amdctl(), set_log_level(), member_summary(), apply_leave()]; debug!(commands = ?commands.iter().map(|c| &c.name).collect::>()); commands } diff --git a/src/commands/summary.rs b/src/commands/summary.rs new file mode 100755 index 0000000..01cd758 --- /dev/null +++ b/src/commands/summary.rs @@ -0,0 +1,92 @@ +use crate::graphql::models::{LeaveCountRecord, MemberSummary}; +use crate::ids::THE_LAB_CHANNEL_ID; +use crate::{Data, Error}; +use chrono::{Datelike, Local, NaiveDate}; +use poise::serenity_prelude::User; +use serenity::all::{ChannelId, CreateEmbed, CreateMessage}; + +#[poise::command(slash_command)] +pub async fn member_summary( + ctx: poise::Context<'_, Data, Error>, + #[description = "Mention the member"] member: User, + #[description = "Start Date (YYYY-MM-DD)"] start_date: Option, + #[description = "End Date (YYYY-MM-DD)"] end_date: Option, +) -> Result<(), Error> { + ctx.defer().await?; + let discord_id = member.id.get().to_string(); + + // take present date automatically and give this month's date and summary if both + let (start_date, end_date) = match (start_date, end_date) { + (Some(s), Some(e)) => (s, e), + + (None, None) => { + let time = Local::now(); + let year = time.year(); + let month = time.month(); + let end = time.date_naive(); + + let (target_year, target_month) = if end.day() == 1 { + if month == 1 { + (year - 1, 12) + } else { + (year, month - 1) + } + } else { + (year, month) + }; + + let start = NaiveDate::from_ymd_opt(target_year, target_month, 1) + .ok_or_else(|| anyhow::anyhow!("Invalid date"))?; + + (start, end) + } + _ => { + return Err( + anyhow::anyhow!("Either provide both start and end dates, or none.").into(), + ); + } + }; + if start_date > end_date { + return Err(anyhow::anyhow!("start_date must be on/before end_date").into()); + } + + let leaves: LeaveCountRecord = ctx + .data() + .graphql_client + .fetch_leaves(&discord_id, start_date, end_date) + .await?; + + let summary: MemberSummary = ctx + .data() + .graphql_client + .fetch_member_summary(&discord_id, start_date, end_date) + .await?; + + let embed = CreateEmbed::new() + .title("Member SummaryπŸ“‹") + .description(format!( + "**Report of** <@{}> + β€’ Period: **{} β†’ {}**\n\n\ + **Attendance πŸ“Š**\n\ + β€’ Presence: **{:.1}%**\n\n\ + **Updates πŸ“**\n\ + β€’ Consistency: **{:.1}%** \n\n\ + **Leave Summary πŸ“„**\n\ + β€’ Total Leave Days: **{}**", + discord_id, + start_date, + end_date, + summary.present_percent, + summary.updates_percent, + leaves.leave_count + )); + + let lab_channel_id = ChannelId::new(THE_LAB_CHANNEL_ID); + lab_channel_id + .send_message(ctx.http(), CreateMessage::new().add_embed(embed)) + .await?; + ctx.say("βœ… Member summary has been posted to the lab channel.") + .await?; + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs old mode 100644 new mode 100755 diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index f8b6415..f7c34f9 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -16,6 +16,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ pub mod models; +pub mod mutations; pub mod queries; use std::sync::Arc; diff --git a/src/graphql/models.rs b/src/graphql/models.rs old mode 100644 new mode 100755 index 4a0892f..a67a71f --- a/src/graphql/models.rs +++ b/src/graphql/models.rs @@ -15,8 +15,8 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ +use chrono::{NaiveDate, NaiveDateTime}; use serde::Deserialize; - #[derive(Clone, Debug, Deserialize)] pub struct StatusOnDate { #[serde(rename = "isSent")] @@ -66,3 +66,41 @@ pub struct AttendanceRecord { #[serde(rename = "timeIn")] pub time_in: Option, } + +#[derive(Debug, Deserialize, Clone)] +pub struct MemberSummary { + #[serde(rename = "presentPercent")] + pub present_percent: f32, + #[serde(rename = "updatesPercent")] + pub updates_percent: f32, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LeaveCountRecord { + #[serde(rename = "discordId")] + pub discord_id: String, + #[serde(rename = "leaveCount")] + pub leave_count: i32, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LeaveRecord { + #[serde(rename = "discordId")] + pub discord_id: String, + #[serde(rename = "fromDate")] + pub from_date: NaiveDate, + pub duration: i32, + pub reason: Option, + #[serde(rename = "approvedBy")] + pub approved_by: Option, + #[serde(rename = "appliedAt")] + pub applied_at: NaiveDateTime, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LeaveRecordWithMessage { + #[serde(flatten)] + pub leave: LeaveRecord, + #[serde(rename = "messageId")] + pub message_id: String, +} diff --git a/src/graphql/mutations.rs b/src/graphql/mutations.rs new file mode 100755 index 0000000..4c44f0f --- /dev/null +++ b/src/graphql/mutations.rs @@ -0,0 +1,132 @@ +use crate::graphql::models::LeaveRecord; +use anyhow::Context; +use serde_json::Value; +use tracing::debug; + +use super::GraphQLClient; +use chrono::NaiveDate; + +impl GraphQLClient { + pub async fn apply_leave( + &self, + discord_id: &str, + message_id: &u64, + start_date: NaiveDate, + duration: i32, + reason: &str, + ) -> anyhow::Result { + let query = r#" + mutation($discord_id: String!, $start_date: String!, $duration: Int!, $reason: String, $message_id: String) { + leaveApplication( + discordId: $discord_id, + fromDate: $start_date, + duration: $duration, + reason: $reason, + messageId: $message_id + ) { + discordId, + fromDate, + duration, + reason, + approvedBy, + appliedAt + } + } + "#; + + let variables = serde_json::json!({ + "discord_id": discord_id, + "start_date": start_date.format("%Y-%m-%d").to_string(), + "duration": duration, + "reason": reason, + "message_id": message_id.to_string() + }); + + debug!("Sending query {}", query); + debug!("With variables: {:?}", variables); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ + "query": query, + "variables": variables + })) + .send() + .await + .context("Failed to successfully post request")?; + + let json: Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + let leave_value = json["data"]["leaveApplication"].clone(); + + let leave: LeaveRecord = + serde_json::from_value(leave_value).context("Failed to deserialize LeaveRecord")?; + + Ok(leave) + } + + pub async fn approve_leave( + &self, + discord_id: &str, + from_date: NaiveDate, + approved_by: &str, + ) -> anyhow::Result { + let query = r#" + mutation($discord_id: String!, $mentor_discord_id : String!, $from_date : String!) { + approveLeave( + discordId: $discord_id, + approvedBy: $mentor_discord_id, + fromDate: $from_date + ) { + discordId, + fromDate, + duration, + reason, + approvedBy, + appliedAt + } + } + "#; + + let variables = serde_json::json!({ + "discord_id": discord_id, + "mentor_discord_id" : approved_by, + "from_date": from_date.format("%Y-%m-%d").to_string() + }); + + debug!("Sending query {}", query); + debug!("With variables: {:?}", variables); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ + "query": query, + "variables": variables + })) + .send() + .await + .context("Failed to successfully post request")?; + + let json: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + if let Some(errors) = json.get("errors") { + anyhow::bail!("GraphQL errors: {}", errors); + } + + let leave_value = json["data"]["approveLeave"].clone(); + + let leave: LeaveRecord = + serde_json::from_value(leave_value).context("Failed to deserialize LeaveRecord")?; + Ok(leave) + } +} diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs old mode 100644 new mode 100755 index 4a737fc..b74a0f3 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -20,7 +20,9 @@ use chrono::{Local, NaiveDate}; use serde_json::Value; use tracing::debug; -use crate::graphql::models::{AttendanceRecord, Member}; +use crate::graphql::models::{ + AttendanceRecord, LeaveCountRecord, LeaveRecordWithMessage, Member, MemberSummary, +}; use super::GraphQLClient; @@ -147,6 +149,182 @@ impl GraphQLClient { Ok(attendance) } + pub async fn fetch_member_summary( + &self, + discord_id: &str, + start_date: NaiveDate, + end_date: NaiveDate, + ) -> anyhow::Result { + let query: &str = r#" + query($discord_id: String!, $start_date: NaiveDate!, $end_date: NaiveDate!){ + member(discordId : $discord_id){ + attendance{ + presentCount(startDate : $start_date,endDate : $end_date) + absentCount(startDate : $start_date,endDate : $end_date) + } + status{ + updateCount(startDate : $start_date,endDate : $end_date) + } + } + } + "#; + + let variables = serde_json::json!({ + "start_date": start_date.format("%Y-%m-%d").to_string(), + "end_date": end_date.format("%Y-%m-%d").to_string(), + "discord_id": discord_id + }); + + debug!("Sending query {}", query); + debug!("With variables {:?}", variables); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ "query": query , "variables":variables})) + .send() + .await + .context("Failed to send GraphQL request")?; + debug!("Response status: {:?}", response.status()); + + let json: Value = response + .json() + .await + .context("Failed to parse response as JSON")?; + + debug!("Response JSON: {:#?}", json); + + let attendance = &json["data"]["member"]["attendance"]; + let status = &json["data"]["member"]["status"]; + + let present: i32 = attendance["presentCount"].as_i64().unwrap_or(0) as i32; + let absent: i32 = attendance["absentCount"].as_i64().unwrap_or(0) as i32; + let updates: i32 = status["updateCount"].as_i64().unwrap_or(0) as i32; + + let total_attendance = present + absent; + + let attendance_percent = if total_attendance == 0 { + 0.0 + } else { + (present as f32 * 100.0) / total_attendance as f32 + }; + + let total_days = (end_date - start_date).num_days() + 1; + + if total_days < 0 { + return Err(anyhow!("end_date must be on/after start_date")); + } + + let total_days = (total_days + 1).max(1) as f32; + + let update_percent = (updates as f32 * 100.0) / total_days; + + let summary = MemberSummary { + present_percent: attendance_percent, + updates_percent: update_percent, + }; + + Ok(summary) + } + + pub async fn fetch_leaves( + &self, + discord_id: &str, + start_date: NaiveDate, + end_date: NaiveDate, + ) -> anyhow::Result { + let query = r#" + query ($discord_id: String!, $start_date: String!, $end_date: String!) { + member(discordId :$discord_id ) { + leaveCount(startDate: $start_date,endDate: $end_date) + } + } + "#; + + let variables = serde_json::json!({ + "discord_id": discord_id, + "start_date": start_date.format("%Y-%m-%d").to_string(), + "end_date": end_date.format("%Y-%m-%d").to_string(), + }); + + debug!("Sending query {}", query); + debug!("With variables {:?}", variables); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ + "query": query, + "variables": variables + })) + .send() + .await + .context("Failed to send GraphQL request")?; + + debug!("Response status: {:?}", response.status()); + + let json: Value = response + .json() + .await + .context("Failed to parse response as JSON")?; + + let leaves: LeaveCountRecord = LeaveCountRecord { + discord_id: discord_id.to_string(), + leave_count: json["data"]["member"]["leaveCount"].as_i64().unwrap_or(0) as i32, + }; + + Ok(leaves) + } + + pub async fn check_leave(&self, message_id: u64) -> anyhow::Result { + let query = r#" + query($message_id: String!) { + leaveByMessageId( + messageId: $message_id + ) { + discordId + fromDate + duration + messageId + approvedBy + appliedAt + } + } + "#; + + let variables = serde_json::json!({ + "message_id": message_id.to_string() + }); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ + "query": query, + "variables": variables + })) + .send() + .await?; + + let json: serde_json::Value = response.json().await?; + + if let Some(errors) = json.get("errors") { + anyhow::bail!("GraphQL errors: {:#}", errors); + } + + let leave_value = json + .get("data") + .and_then(|data| data.get("leaveByMessageId")) + .ok_or_else(|| anyhow::anyhow!("Missing data.leaveByMessageId"))?; + + let leave: LeaveRecordWithMessage = serde_json::from_value(leave_value.clone())?; + + Ok(leave) + } + pub async fn save_member_roles( &self, discord_id: String, diff --git a/src/main.rs b/src/main.rs old mode 100644 new mode 100755 index de181b5..ae9bdda --- a/src/main.rs +++ b/src/main.rs @@ -192,7 +192,58 @@ async fn event_handler( match event { FullEvent::ReactionAdd { add_reaction } => { handle_reaction(ctx, add_reaction, data, true).await?; + + if add_reaction.user_id == Some(ctx.cache.current_user().id) { + return Ok(()); + } + + if add_reaction.emoji != ReactionType::Unicode("βœ…".into()) { + return Ok(()); + } + + let channel_id = add_reaction.channel_id; + let message_id = add_reaction.message_id; + + let reacted_by_id = if let Some(id) = add_reaction.user_id { + id + } else { + return Ok(()); + }; + + let message = channel_id.message(ctx, message_id).await?; + + let details = data.graphql_client.check_leave(message_id.get()).await?; + + let discord_id = &details.leave.discord_id; + let from_date = details.leave.from_date; + + let approver_id = reacted_by_id.get().to_string(); + + if approver_id == *discord_id { + message + .reply(ctx, "❌ You cannot approve your own leave.") + .await?; + return Ok(()); + } + + match data + .graphql_client + .approve_leave(discord_id, from_date, &approver_id) + .await + { + Ok(_) => { + message + .reply(ctx, format!("βœ… Leave approved by <@{}>", approver_id)) + .await?; + } + Err(err) => { + eprintln!("approve_leave failed: {:?}", err); + + message.reply(ctx, "❌ Failed to approve leave.").await?; + } + } } + FullEvent::ReactionRemove { removed_reaction } => { handle_reaction(ctx, removed_reaction, data, false).await?; } @@ -243,6 +294,7 @@ async fn event_handler( } } } + _ => {} } diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index 998fb75..314d4ed 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -169,9 +169,9 @@ fn format_breaks(mut years_on_break: Vec) -> String { 1 => "First Years", 2 => "Second Years", 3 => "Third Years", - _ => return format!("Year {}", year), + _ => return format!("Year {year}"), }; - format!("- {}", year_label) + format!("- {year_label}") }) .collect::>() .join("\n");