diff --git a/migrations/20260807145605_create_leaves_table.sql b/migrations/20260807145605_create_leaves_table.sql new file mode 100644 index 0000000..1dacbf8 --- /dev/null +++ b/migrations/20260807145605_create_leaves_table.sql @@ -0,0 +1,14 @@ +-- Leave table for tracking leaves +CREATE TABLE Leave ( + leave_id SERIAL PRIMARY KEY, + message_id INT NOT NULL UNIQUE, + discord_id VARCHAR(255) NOT NULL REFERENCES Member(discord_id) ON DELETE CASCADE, + from_date DATE DEFAULT CURRENT_DATE NOT NULL, + duration INT DEFAULT 1 NOT NULL, + reason TEXT NOT NULL, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + approved_by VARCHAR(255) REFERENCES Member(discord_id) ON DELETE SET NULL, + CHECK (approved_by IS NULL OR approved_by <> discord_id), + CHECK (duration > 0), + UNIQUE (from_date, discord_id) +); \ No newline at end of file diff --git a/src/graphql/mutations/attendance_mutations.rs b/src/graphql/mutations/attendance_mutations.rs index 953b032..96a7395 100644 --- a/src/graphql/mutations/attendance_mutations.rs +++ b/src/graphql/mutations/attendance_mutations.rs @@ -1,13 +1,14 @@ use std::sync::Arc; use async_graphql::{Context, Object, Result}; +use chrono::NaiveDate; use chrono_tz::Asia::Kolkata; use hmac::{Hmac, Mac}; use sha2::Sha256; use sqlx::PgPool; use crate::auth::guards::AdminOrBotGuard; -use crate::models::attendance::{AttendanceRecord, MarkAttendanceInput}; +use crate::models::attendance::{AttendanceRecord, LeaveRecord, MarkAttendanceInput}; type HmacSha256 = Hmac; @@ -61,4 +62,67 @@ impl AttendanceMutations { Ok(attendance) } + + #[graphql(name = "leaveApplication", guard = "AdminOrBotGuard")] + async fn leave_application( + &self, + ctx: &Context<'_>, + discord_id: String, + message_id: String, + reason: String, + from_date: NaiveDate, + duration: i32, + ) -> Result { + let pool = ctx + .data::>() + .expect("Pool not found in context"); + + let leave: LeaveRecord = sqlx::query_as::<_, LeaveRecord>( + "INSERT INTO Leave + (discord_id, message_id, reason, from_date, duration) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + ", + ) + .bind(discord_id) + .bind(message_id) + .bind(reason) + .bind(from_date) + .bind(duration) + .fetch_one(pool.as_ref()) + .await?; + + Ok(leave) + } + + #[graphql(name = "approveLeave", guard = "AdminOrBotGuard")] + async fn approve_leave( + &self, + ctx: &Context<'_>, + discord_id: String, + from_date: NaiveDate, + approved_by: String, + ) -> Result { + let pool = ctx + .data::>() + .expect("Pool not found in context"); + + let leave: LeaveRecord = sqlx::query_as::<_, LeaveRecord>( + "UPDATE Leave + SET approved_by = $1 + WHERE discord_id = $2 AND + from_date=$3 AND + approved_by IS NULL + RETURNING * + ", + ) + .bind(approved_by) + .bind(discord_id) + .bind(from_date) + .fetch_optional(pool.as_ref()) + .await? + .ok_or_else(|| async_graphql::Error::new("no pending leave found to approve"))?; + + Ok(leave) + } } diff --git a/src/graphql/queries/member_queries.rs b/src/graphql/queries/member_queries.rs index c15e107..1177864 100644 --- a/src/graphql/queries/member_queries.rs +++ b/src/graphql/queries/member_queries.rs @@ -8,7 +8,9 @@ use chrono::NaiveDate; use sqlx::PgPool; use std::sync::Arc; -use crate::models::{member::Member, status_update::StatusUpdateStreakRecord}; +use crate::models::{ + attendance::CheckLeave, member::Member, status_update::StatusUpdateStreakRecord, +}; #[derive(Default)] pub struct MemberQueries; @@ -59,11 +61,12 @@ impl MemberQueries { ctx: &Context<'_>, member_id: Option, email: Option, + discord_id: Option, ) -> Result> { let pool = ctx.data::>().expect("Pool must be in context."); - match (member_id, email) { - (Some(id), None) => { + match (member_id, email, discord_id) { + (Some(id), None, None) => { let member = sqlx::query_as::<_, Member>("SELECT * FROM Member WHERE member_id = $1") .bind(id) @@ -71,15 +74,22 @@ impl MemberQueries { .await?; Ok(member) } - (None, Some(email)) => { + (None, Some(email), None) => { let member = sqlx::query_as::<_, Member>("SELECT * FROM Member WHERE email = $1") .bind(email) .fetch_optional(pool.as_ref()) .await?; Ok(member) } - (Some(_), Some(_)) => Err("Provide only one of member_id or email".into()), - (None, None) => Err("Provide either member_id or email".into()), + (None, None, Some(discord_id)) => { + let member = + sqlx::query_as::<_, Member>("SELECT * FROM Member WHERE discord_id = $1") + .bind(discord_id) + .fetch_optional(pool.as_ref()) + .await?; + Ok(member) + } + _ => Err("Provide exactly one of member_id, email, or discord_id".into()), } } @@ -119,6 +129,34 @@ impl MemberQueries { }) } } + + #[graphql(guard = "AuthGuard")] + async fn leave_by_message_id( + &self, + ctx: &Context<'_>, + #[graphql(name = "messageId")] message_id: i64, + ) -> Result { + let pool = ctx.data::>()?; + + let row: Option<(i64, i64, NaiveDate, i64, NaiveDate, Option)> = sqlx::query_as( + "SELECT message_id, discord_id, from_date, duration, applied_at, approved_by FROM Leave WHERE message_id = $1", + ) + .bind(message_id) + .fetch_optional(pool.as_ref()) + .await?; + + match row { + Some((msg_id, discord_id, from_date, duration, applied_at, approved_by)) => Ok(CheckLeave { + message_id: msg_id, + discord_id, + from_date, + duration, + applied_at, + approved_by: approved_by.unwrap_or_else(|| "bot".to_string()), + }), + None => Err("No leave found for given message_id".into()), + } + } } #[Object] @@ -419,4 +457,41 @@ impl Member { member_id: self.member_id, } } + + async fn leave_count( + &self, + ctx: &Context<'_>, + start_date: NaiveDate, + end_date: NaiveDate, + ) -> Result { + let pool = ctx.data::>()?; + + if end_date < start_date { + return Err("end_date must be >= start_date".into()); + } + let discord_id = self + .discord_id + .as_ref() + .expect("Leave count needs discord_id"); + + let total: Option = sqlx::query_scalar( + r#" + SELECT SUM( + LEAST(from_date + duration - 1, $2) + - GREATEST(from_date, $1) + + 1 + ) + FROM leave + WHERE from_date <= $2 + AND (from_date + duration - 1) >= $1 + AND discord_id = $3 + "#, + ) + .bind(start_date) + .bind(end_date) + .bind(discord_id) + .fetch_one(pool.as_ref()) + .await?; + Ok(total.unwrap_or(0)) + } } diff --git a/src/models/attendance.rs b/src/models/attendance.rs index 8647774..d0bb426 100644 --- a/src/models/attendance.rs +++ b/src/models/attendance.rs @@ -21,3 +21,23 @@ pub struct MarkAttendanceInput { pub date: NaiveDate, pub hmac_signature: String, } + +#[derive(SimpleObject, FromRow)] +pub struct LeaveRecord { + pub discord_id: String, + pub from_date: NaiveDate, + pub applied_at: NaiveDateTime, + pub reason: String, + pub duration: i32, + pub approved_by: Option, +} + +#[derive(SimpleObject)] +pub struct CheckLeave { + pub message_id: i64, + pub discord_id: i64, + pub from_date: NaiveDate, + pub duration: i64, + pub applied_at: NaiveDate, + pub approved_by: String, +}