diff --git a/migrations/20260214152404_create_leave_table.sql b/migrations/20260214152404_create_leave_table.sql new file mode 100644 index 0000000..9246f67 --- /dev/null +++ b/migrations/20260214152404_create_leave_table.sql @@ -0,0 +1,12 @@ +-- Leave table for tracking leaves +CREATE TABLE Leave ( + leave_id SERIAL PRIMARY KEY, + discord_id VARCHAR(255) REFERENCES Member(discord_id) ON DELETE CASCADE, + from_date DATE DEFAULT CURRENT_DATE, + duration INT DEFAULT 1, + reason TEXT NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + approved_by VARCHAR(255) REFERENCES Member(discord_id), + CHECK (approved_by IS NULL OR approved_by <> discord_id), + UNIQUE (from_date, discord_id) +); diff --git a/src/graphql/mutations/attendance_mutations.rs b/src/graphql/mutations/attendance_mutations.rs index 953b032..e348e23 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, NaiveDateTime}; 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, MarkAttendanceInput, MarkLeaveOutput}; type HmacSha256 = Hmac; @@ -61,4 +62,62 @@ impl AttendanceMutations { Ok(attendance) } + + #[graphql(name = "leaveApplication", guard = "AdminOrBotGuard")] + async fn leave_application( + &self, + ctx: &Context<'_>, + discord_id: String, + reason: String, + applied_at: NaiveDateTime, + from_date: NaiveDate, + duration: i32, + ) -> Result { + let pool = ctx + .data::>() + .expect("Pool not found in context"); + + let leave: MarkLeaveOutput = sqlx::query_as::<_, MarkLeaveOutput>( + "INSERT INTO Leave + (discord_id, reason, applied_at, from_date, duration) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + ", + ) + .bind(discord_id) + .bind(reason) + .bind(applied_at) + .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, + approved_by: String, + ) -> Result { + let pool = ctx + .data::>() + .expect("Pool not found in context"); + + let leave: MarkLeaveOutput = sqlx::query_as::<_, MarkLeaveOutput>( + "UPDATE Leave + SET approved_by = $1 + WHERE discord_id = $2 + RETURNING * + ", + ) + .bind(approved_by) + .bind(discord_id) + .fetch_one(pool.as_ref()) + .await?; + + Ok(leave) + } } diff --git a/src/graphql/queries/member_queries.rs b/src/graphql/queries/member_queries.rs index 2b0b1af..5bf2d1c 100644 --- a/src/graphql/queries/member_queries.rs +++ b/src/graphql/queries/member_queries.rs @@ -57,11 +57,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) @@ -69,19 +70,26 @@ 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()), } } - /// Fetch the details of the currently logged in member + // Fetch the details of the currently logged in member #[graphql(guard = "AuthGuard")] async fn me(&self, ctx: &Context<'_>) -> Result { let auth = ctx.data::()?; @@ -389,4 +397,40 @@ 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::>().expect("Pool must be in context."); + + if end_date < start_date { + return Err("end_date must be >= start_date".into()); + } + 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( + self.discord_id + .as_ref() + .expect("Leave count needs 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..4620892 100644 --- a/src/models/attendance.rs +++ b/src/models/attendance.rs @@ -21,3 +21,13 @@ pub struct MarkAttendanceInput { pub date: NaiveDate, pub hmac_signature: String, } + +#[derive(SimpleObject, FromRow)] +pub struct MarkLeaveOutput { + pub discord_id: String, + pub from_date: NaiveDate, + pub applied_at: NaiveDateTime, + pub reason: String, + pub duration: i32, + pub approved_by: Option, +}