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) = ¶ms.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) = ¶ms.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}