api/routes/modules/announcements/
get.rs

1//! Get announcements handler.
2//!
3//! Provides an endpoint to retrieve a paginated list of announcements for a specific module.
4//!
5//! Supports filtering by search query, pinned status, and sorting by various fields.
6
7use crate::response::ApiResponse;
8use axum::{
9    extract::{Path, Query, State},
10    http::StatusCode,
11    response::{IntoResponse, Json},
12};
13use sea_orm::{ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
14use serde::{Deserialize, Serialize};
15use util::state::AppState;
16use db::models::announcements::{
17    Column as AnnouncementColumn, Entity as AnnouncementEntity, Model as AnnouncementModel,
18};
19use db::models::user::{Entity as UserEntity};
20
21#[derive(Serialize)]
22pub struct MinimalUser {
23    pub id: i64,
24    pub username: String,
25}
26
27#[derive(Serialize)]
28pub struct ShowAnnouncementResponse {
29    pub announcement: AnnouncementModel,
30    pub user: MinimalUser,
31}
32
33
34#[derive(Debug, Deserialize)]
35pub struct FilterReq {
36    pub page: Option<i32>,
37    pub per_page: Option<i32>,
38    pub query: Option<String>,
39    pub pinned: Option<String>,
40    pub sort: Option<String>,
41}
42
43#[derive(Serialize)]
44pub struct FilterResponse {
45    pub announcements: Vec<AnnouncementModel>,
46    pub page: i32,
47    pub per_page: i32,
48    pub total: i32,
49}
50
51impl FilterResponse {
52    fn new(announcements: Vec<AnnouncementModel>, page: i32, per_page: i32, total: i32) -> Self {
53        Self {
54            announcements,
55            page,
56            per_page,
57            total,
58        }
59    }
60}
61
62/// GET /api/modules/{module_id}/announcements
63///
64/// Retrieves a paginated and optionally filtered list of announcements for a specific module.
65/// By default, results are sorted with pinned announcements first (`pinned DESC`)  
66/// and then by most recent creation date (`created_at DESC`).  
67/// This ensures pinned announcements always appear at the top, with the newest first.
68/// If the user explicitly includes `pinned` in the `sort` parameter, the default is overridden.
69///
70/// # Path Parameters
71///
72/// - `module_id`: The ID of the module to retrieve announcements for.
73///
74/// # Query Parameters
75///
76/// Extracted via the `FilterReq` struct:
77/// - `page`: (Optional) Page number for pagination. Defaults to 1. Minimum is 1.
78/// - `per_page`: (Optional) Number of items per page. Defaults to 20. Maximum is 100. Minimum is 1.
79/// - `query`: (Optional) General search string. Matches announcements by `title` or `body`.
80/// - `pinned`: (Optional) Filter by pinned status. Accepts `true` or `false`.
81/// - `sort`: (Optional) Comma-separated list of fields to sort by.  
82///   Prefix with `-` for descending order (e.g., `-created_at`).  
83///   Allowed fields: `"created_at"`, `"updated_at"`, `"title"`, `"pinned"`.
84///
85/// # Sorting Behavior
86///
87/// - **Default**: If `sort` is not provided or does not include `pinned`,  
88///   results are automatically sorted by:
89///   1. `pinned DESC` (pinned items first)
90///   2. `created_at DESC` (newest items first)
91/// - If `pinned` is explicitly included in `sort`, that order is respected and overrides the default.
92///
93/// # Returns
94///
95/// Returns an HTTP response in the standardized API format:
96///
97/// - `200 OK`: Successfully retrieved the paginated list of announcements.
98/// - `400 BAD REQUEST`: Invalid sort field or invalid `pinned` value.
99/// - `500 INTERNAL SERVER ERROR`: Database query failed.
100///
101/// Response contains:
102/// - `announcements`: Array of announcement objects.
103/// - Pagination metadata: `page`, `per_page`, `total`.
104///
105/// # Example Response
106///
107/// **200 OK**
108/// ```json
109/// {
110///   "success": true,
111///   "data": {
112///     "announcements": [
113///       {
114///         "id": 1,
115///         "module_id": 101,
116///         "user_id": 5,
117///         "title": "Important update",
118///         "body": "Please note the following changes...",
119///         "pinned": true,
120///         "created_at": "2025-08-16T12:00:00Z",
121///         "updated_at": "2025-08-16T12:00:00Z"
122///       }
123///     ],
124///     "page": 1,
125///     "per_page": 20,
126///     "total": 45
127///   },
128///   "message": "Announcements retrieved successfully"
129/// }
130/// ```
131///
132/// **400 BAD REQUEST**
133/// ```json
134/// {
135///   "success": false,
136///   "message": "Invalid field used for sorting"
137/// }
138/// ```
139///
140/// **500 INTERNAL SERVER ERROR**
141/// ```json
142/// {
143///   "success": false,
144///   "message": "Failed to retrieve announcements"
145/// }
146/// ```
147pub async fn get_announcements(
148    Path(module_id): Path<i64>,
149    State(app_state): State<AppState>,
150    Query(params): Query<FilterReq>,
151) -> impl IntoResponse {
152    let db = app_state.db();
153
154    let page = params.page.unwrap_or(1).max(1);
155    let per_page = params.per_page.unwrap_or(20).min(100);
156
157    if let Some(sort_field) = &params.sort {
158        let valid_fields = ["created_at", "updated_at", "title", "pinned"];
159        for field in sort_field.split(',') {
160            let field = field.trim().trim_start_matches('-');
161            if !valid_fields.contains(&field) {
162                return (
163                    StatusCode::BAD_REQUEST,
164                    Json(ApiResponse::<FilterResponse>::error("Invalid field used")),
165                )
166                .into_response();
167            }
168        }
169    }
170
171    let mut condition = Condition::all().add(AnnouncementColumn::ModuleId.eq(module_id));
172
173    if let Some(ref query) = params.query {
174        let pattern = format!("%{}%", query.to_lowercase());
175        condition = condition.add(
176            Condition::any()
177                .add(AnnouncementColumn::Title.contains(&pattern))
178                .add(AnnouncementColumn::Body.contains(&pattern)),
179        );
180    }
181
182    if let Some(ref pinned) = params.pinned {
183        match pinned.parse::<bool>() {
184            Ok(pinned_bool) => {
185                condition = condition.add(AnnouncementColumn::Pinned.eq(pinned_bool));
186            }
187            Err(_) => {
188                return (
189                    StatusCode::BAD_REQUEST,
190                    Json(ApiResponse::<FilterResponse>::error("Invalid pinned value")),
191                )
192                .into_response();
193            }
194        }
195    }
196
197    let mut query = AnnouncementEntity::find().filter(condition);
198
199    let mut applied_pinned_sort = false;
200
201    if let Some(sort_param) = &params.sort {
202        for sort in sort_param.split(',') {
203            let (field, asc) = if sort.starts_with('-') {
204                (&sort[1..], false)
205            } else {
206                (sort, true)
207            };
208
209            query = match field {
210                "created_at" => {
211                    if asc {
212                        query.order_by_asc(AnnouncementColumn::CreatedAt)
213                    } else {
214                        query.order_by_desc(AnnouncementColumn::CreatedAt)
215                    }
216                }
217                "updated_at" => {
218                    if asc {
219                        query.order_by_asc(AnnouncementColumn::UpdatedAt)
220                    } else {
221                        query.order_by_desc(AnnouncementColumn::UpdatedAt)
222                    }
223                }
224                "title" => {
225                    if asc {
226                        query.order_by_asc(AnnouncementColumn::Title)
227                    } else {
228                        query.order_by_desc(AnnouncementColumn::Title)
229                    }
230                }
231                "pinned" => {
232                    applied_pinned_sort = true;
233                    if asc {
234                        query.order_by_asc(AnnouncementColumn::Pinned)
235                    } else {
236                        query.order_by_desc(AnnouncementColumn::Pinned)
237                    }
238                }
239                _ => query,
240            };
241        }
242    }
243
244    // Default pinned DESC if not explicitly sorted by pinned
245    if !applied_pinned_sort {
246        query = query.order_by_desc(AnnouncementColumn::Pinned).order_by_desc(AnnouncementColumn::CreatedAt);
247    }
248
249    let paginator = query.clone().paginate(db, per_page as u64);
250    let total = match paginator.num_items().await {
251        Ok(n) => n as i32,
252        Err(e) => {
253            eprintln!("Error counting announcements: {:?}", e);
254            return (
255                StatusCode::INTERNAL_SERVER_ERROR,
256                Json(ApiResponse::<FilterResponse>::error("Error counting announcements")),
257            )
258            .into_response();
259        }
260    };
261
262    match paginator.fetch_page((page - 1) as u64).await {
263        Ok(results) => {
264            let response = FilterResponse::new(results, page, per_page, total);
265            (
266                StatusCode::OK,
267                Json(ApiResponse::success(
268                    response,
269                    "Announcements retrieved successfully",
270                )),
271            )
272            .into_response()
273        }
274        Err(err) => {
275            eprintln!("Error fetching announcements: {:?}", err);
276            (
277                StatusCode::INTERNAL_SERVER_ERROR,
278                Json(ApiResponse::<FilterResponse>::error(
279                    "Failed to retrieve announcements",
280                )),
281            )
282            .into_response()
283        }
284    }
285}
286
287/// GET /api/modules/{module_id}/announcements/{announcement_id}
288///
289/// Retrieves a single announcement by ID for the specified module, including the **authoring user**.
290///
291/// # Path Parameters
292///
293/// - `module_id`: The module the announcement belongs to.
294/// - `announcement_id`: The announcement ID to fetch.
295///
296/// # Behavior
297///
298/// - Verifies the announcement belongs to the given `module_id`.
299/// - Eager-loads the related user (author) via the `belongs_to User` relation.
300/// - Returns `404 NOT FOUND` if no matching announcement is found.
301///
302/// # Returns
303///
304/// - `200 OK` with `{ announcement, user }` on success (user is `{ id, username }` only).
305/// - `404 NOT FOUND` if the announcement does not exist (or doesn’t belong to the module).
306/// - `500 INTERNAL SERVER ERROR` on database errors.
307///
308/// # Example Responses
309///
310/// **200 OK**
311/// ```json
312/// {
313///   "success": true,
314///   "data": {
315///     "announcement": {
316///       "id": 42,
317///       "module_id": 101,
318///       "user_id": 5,
319///       "title": "Important update",
320///       "body": "Please note the following changes...",
321//* pinned/created_at/updated_at omitted for brevity in this snippet
322///       "pinned": true,
323///       "created_at": "2025-08-16T12:00:00Z",
324///       "updated_at": "2025-08-16T12:15:00Z"
325///     },
326///     "user": { "id": 5, "username": "lecturer" }
327///   },
328///   "message": "Announcement retrieved successfully"
329/// }
330/// ```
331///
332/// **404 NOT FOUND**
333/// ```json
334/// { "success": false, "message": "Announcement not found" }
335/// ```
336///
337/// **500 INTERNAL SERVER ERROR**
338/// ```json
339/// { "success": false, "message": "Failed to retrieve announcement" }
340/// ```
341pub async fn get_announcement(
342    State(app_state): State<AppState>,
343    Path((module_id, announcement_id)): Path<(i64, i64)>,
344) -> impl IntoResponse {
345    let db = app_state.db();
346
347    let result = AnnouncementEntity::find_by_id(announcement_id)
348        .filter(AnnouncementColumn::ModuleId.eq(module_id))
349        .find_also_related(UserEntity)
350        .one(db)
351        .await;
352
353    match result {
354        Ok(Some((announcement, Some(user)))) => {
355            let thin = MinimalUser {
356                id: user.id,
357                username: user.username,
358            };
359            (
360                StatusCode::OK,
361                Json(ApiResponse::success(
362                    ShowAnnouncementResponse {
363                        announcement,
364                        user: thin,
365                    },
366                    "Announcement retrieved successfully",
367                )),
368            )
369                .into_response()
370        }
371
372        Ok(Some((_announcement, None))) => (
373            StatusCode::INTERNAL_SERVER_ERROR,
374            Json(ApiResponse::<ShowAnnouncementResponse>::error(
375                "Related user not found for announcement",
376            )),
377        )
378            .into_response(),
379
380        Ok(None) => (
381            StatusCode::NOT_FOUND,
382            Json(ApiResponse::<ShowAnnouncementResponse>::error(
383                "Announcement not found",
384            )),
385        )
386            .into_response(),
387
388        Err(err) => {
389            eprintln!("Error fetching announcement: {:?}", err);
390            (
391                StatusCode::INTERNAL_SERVER_ERROR,
392                Json(ApiResponse::<ShowAnnouncementResponse>::error(
393                    "Failed to retrieve announcement",
394                )),
395            )
396                .into_response()
397        }
398    }
399}