api/routes/me/
events.rs

1use axum::{
2    extract::{Query, State},
3    http::StatusCode,
4    response::IntoResponse,
5    Json,
6};
7use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use validator::Validate;
11use crate::{
12    auth::claims::AuthUser,
13    response::ApiResponse,
14};
15use common::format_validation_errors;
16use db::models::{assignment, module, user_module_role};
17use sea_orm::{ColumnTrait, Condition, EntityTrait, JoinType, QueryFilter, RelationTrait, QuerySelect};
18use util::state::AppState;
19
20/// Query parameters for filtering events.
21#[derive(Debug, Deserialize, Validate)]
22pub struct GetEventsQuery {
23    /// Start date filter in ISO 8601 (`YYYY-MM-DD` or `YYYY-MM-DDTHH:mm:ss`)
24    #[serde(default)]
25    pub from: Option<String>,
26    
27    /// End date filter in ISO 8601 (`YYYY-MM-DD` or `YYYY-MM-DDTHH:mm:ss`)
28    #[serde(default)]
29    pub to: Option<String>,
30}
31
32/// Represents a single event item
33#[derive(Debug, Serialize)]
34pub struct EventItem {
35    pub r#type: String,
36    pub content: String,
37}
38
39/// Response structure for listing events
40#[derive(Debug, Serialize)]
41pub struct EventsResponse {
42    pub events: HashMap<String, Vec<EventItem>>,
43}
44
45/// Normalize timestamp to ISO 8601 without fractional seconds
46fn format_iso_datetime(dt: DateTime<Utc>) -> String {
47    dt.format("%Y-%m-%dT%H:%M:%S").to_string()
48}
49
50/// Parse optional datetime string
51fn parse_optional_datetime(s: Option<&str>) -> Result<Option<DateTime<Utc>>, String> {
52    let Some(s) = s else {
53        return Ok(None);
54    };
55
56    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
57        return Ok(Some(dt.with_timezone(&Utc)));
58    }
59
60    if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
61        let datetime = date.and_time(NaiveTime::MIN).and_utc();
62        return Ok(Some(datetime));
63    }
64
65    if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
66        return Ok(Some(Utc.from_utc_datetime(&naive_dt)));
67    }
68
69    Err("Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss".to_string())
70}
71
72/// GET handler for `/api/me/events`
73/// 
74/// Retrieves calendar events for the authenticated user within an optional date range.
75/// Events include assignment availability dates and due dates, grouped by their timestamps.
76///
77/// ### Query Parameters
78/// - `from` (optional): Start date filter in ISO 8601 format (`YYYY-MM-DD` or `YYYY-MM-DDTHH:mm:ss`)
79/// - `to` (optional): End date filter in ISO 8601 format (`YYYY-MM-DD` or `YYYY-MM-DDTHH:mm:ss`)
80///
81/// ### Response
82/// - `200 OK`: Returns a map where:
83///   - Keys are ISO 8601 timestamps without fractional seconds
84///   - Values are arrays of events occurring at that timestamp
85///   - Each event has a type ("warning" for availability, "error" for due dates)
86///   - Event content describes the assignment name and event type
87pub async fn get_my_events(
88    State(state): State<AppState>,
89    axum::Extension(user): axum::Extension<AuthUser>,
90    Query(query): Query<GetEventsQuery>,
91) -> impl IntoResponse {
92    if let Err(e) = query.validate() {
93        return (
94            StatusCode::BAD_REQUEST,
95            Json(ApiResponse::<()>::error(format_validation_errors(&e))),
96        ).into_response();
97    }
98
99    let user_id = user.0.sub;
100
101    let from_dt = match parse_optional_datetime(query.from.as_deref()) {
102        Ok(dt) => dt,
103        Err(msg) => {
104            return (
105                StatusCode::BAD_REQUEST,
106                Json(ApiResponse::<()>::error(msg)),
107            ).into_response();
108        }
109    };
110    
111    let to_dt = match parse_optional_datetime(query.to.as_deref()) {
112        Ok(dt) => dt,
113        Err(msg) => {
114            return (
115                StatusCode::BAD_REQUEST,
116                Json(ApiResponse::<()>::error(msg)),
117            ).into_response();
118        }
119    };
120    
121    if let (Some(from), Some(to)) = (from_dt, to_dt) {
122        if from > to {
123            return (
124                StatusCode::BAD_REQUEST,
125                Json(ApiResponse::<()>::error(
126                    "'from' date must be before 'to' date".to_string()
127                )),
128            ).into_response();
129        }
130    }
131
132    let mut events_map: HashMap<String, Vec<EventItem>> = HashMap::new();
133
134    let mut assignment_query = assignment::Entity::find()
135        .join(JoinType::InnerJoin, assignment::Relation::Module.def())
136        .join(
137            JoinType::InnerJoin,
138            module::Relation::UserModuleRole.def(),
139        )
140        .filter(user_module_role::Column::UserId.eq(user_id));
141
142    let mut date_conditions = Condition::all();
143    
144    if let Some(from) = from_dt {
145        date_conditions = date_conditions.add(
146            Condition::any()
147                .add(assignment::Column::AvailableFrom.gte(from))
148                .add(assignment::Column::DueDate.gte(from))
149        );
150    }
151    
152    if let Some(to) = to_dt {
153        date_conditions = date_conditions.add(
154            Condition::any()
155                .add(assignment::Column::AvailableFrom.lte(to))
156                .add(assignment::Column::DueDate.lte(to))
157        );
158    }
159
160    assignment_query = assignment_query.filter(date_conditions);
161
162    let assignments = match assignment_query.all(state.db()).await {
163        Ok(assignments) => assignments,
164        Err(e) => {
165            return (
166                StatusCode::INTERNAL_SERVER_ERROR,
167                Json(ApiResponse::<()>::error(format!("Database error: {}", e))),
168            ).into_response();
169        }
170    };
171
172    for assignment in assignments {
173        let available_in_range = match (from_dt, to_dt) {
174            (Some(from), Some(to)) => assignment.available_from >= from && assignment.available_from <= to,
175            (Some(from), None) => assignment.available_from >= from,
176            (None, Some(to)) => assignment.available_from <= to,
177            (None, None) => true,
178        };
179
180        if available_in_range {
181            let available_key = format_iso_datetime(assignment.available_from);
182            events_map
183                .entry(available_key)
184                .or_default()
185                .push(EventItem {
186                    r#type: "warning".to_string(),
187                    content: format!("{} available", assignment.name),
188                });
189        }
190
191        let due_in_range = match (from_dt, to_dt) {
192            (Some(from), Some(to)) => assignment.due_date >= from && assignment.due_date <= to,
193            (Some(from), None) => assignment.due_date >= from,
194            (None, Some(to)) => assignment.due_date <= to,
195            (None, None) => true,
196        };
197
198        if due_in_range {
199            let due_key = format_iso_datetime(assignment.due_date);
200            events_map
201                .entry(due_key)
202                .or_default()
203                .push(EventItem {
204                    r#type: "error".to_string(),
205                    content: format!("{} due", assignment.name),
206                });
207        }
208    }
209
210    (
211        StatusCode::OK,
212        Json(ApiResponse::success(
213            EventsResponse { events: events_map },
214            "Events retrieved successfully",
215        )),
216    ).into_response()
217}