api/routes/me/
submissions.rs

1use axum::{
2    extract::{Query, State},
3    http::StatusCode,
4    response::IntoResponse,
5    Extension, Json,
6};
7use common::format_validation_errors;
8use sea_orm::{
9    ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, RelationTrait, QuerySelect, FromQueryResult, prelude::Expr,
10};
11use sea_orm_migration::prelude::Alias;
12use serde::{Deserialize, Serialize};
13use validator::Validate;
14
15use crate::{auth::claims::AuthUser, response::ApiResponse};
16use db::models::{
17    assignment,
18    assignment_submission::{self, Column as SubmissionColumn, Entity as SubmissionEntity},
19    module,
20    user,
21    user_module_role::{self, Column as RoleColumn, Role},
22};
23use util::state::AppState;
24
25#[derive(Debug, Deserialize, Validate)]
26pub struct GetSubmissionsQuery {
27    #[validate(range(min = 1))]
28    pub page: Option<u64>,
29    #[validate(range(min = 1, max = 100))]
30    pub per_page: Option<u64>,
31    pub query: Option<String>,
32    pub role: Option<Role>,
33    pub year: Option<i32>,
34    pub is_late: Option<bool>,
35    pub sort: Option<String>,
36}
37
38#[derive(Debug, Serialize)]
39pub struct SubmissionItem {
40    pub id: i64,
41    pub status: String,
42    pub score: Score,
43    pub created_at: String,
44    pub updated_at: String,
45    pub is_late: bool,
46    pub module: ModuleInfo,
47    pub assignment: AssignmentInfo,
48    pub user: UserInfo,
49}
50
51#[derive(Debug, Serialize)]
52pub struct Score {
53    pub earned: i64,
54    pub total: i64,
55}
56
57#[derive(Debug, Serialize)]
58pub struct ModuleInfo {
59    pub id: i64,
60    pub code: String,
61}
62
63#[derive(Debug, Serialize)]
64pub struct AssignmentInfo {
65    pub id: i64,
66    pub name: String,
67    pub description: Option<String>,
68}
69
70#[derive(Debug, Serialize)]
71pub struct UserInfo {
72    pub id: i64,
73    pub username: String,
74}
75
76#[derive(Debug, Serialize)]
77pub struct GetSubmissionsResponse {
78    pub submissions: Vec<SubmissionItem>,
79    pub page: u64,
80    pub per_page: u64,
81    pub total: u64,
82}
83
84#[derive(Debug, FromQueryResult)]
85pub struct SubmissionWithRelations {
86    pub id: i64,
87    pub earned: i64,
88    pub total: i64,
89    pub created_at: chrono::NaiveDateTime,
90    pub updated_at: chrono::NaiveDateTime,
91    pub user_id: i64,
92    pub assignment_id: i64,
93    pub assignment_name: String,
94    pub assignment_description: Option<String>,
95    pub assignment_due_date: chrono::NaiveDateTime,
96    pub module_id: i64,
97    pub module_code: String,
98    pub username: String,
99}
100
101/// GET /api/me/submissions
102///
103/// Retrieves a paginated list of submissions relevant to the authenticated user.
104/// The behavior of this endpoint changes based on the `role` query parameter,
105/// allowing users to view submissions based on their permissions.
106///
107/// ### Authorization
108/// Requires a valid bearer token.
109///
110/// ### Query Parameters
111/// - `page` (optional, u64, min: 1): The page number for pagination. Defaults to 1.
112/// - `per_page` (optional, u64, min: 1, max: 100): The number of items per page. Defaults to 20.
113/// - `query` (optional, string): A search term to filter submissions by module code, student username, or assignment name.
114/// - `role` (optional, string): The role to filter by. Can be `Student`, `Tutor`, `AssistantLecturer`, or `Lecturer`. Defaults to `Student`.
115///   - If `Student`, returns only the authenticated user's submissions.
116///   - If `Lecturer`, `Tutor`, etc., returns submissions for all students in modules where the user holds that role.
117/// - `year` (optional, i32): Filters submissions by the module's academic year.
118/// - `is_late` (optional, bool): Filters submissions based on whether they were submitted after the assignment's due date.
119/// - `sort` (optional, string): A comma-separated list of fields to sort by. Prefix with `-` for descending order.
120///   - Allowed fields: `score`, `created_at`.
121///
122/// ### Response: 200 OK
123/// Returns a paginated list of submissions.
124/// ```json
125/// {
126///   "success": true,
127///   "message": "Submissions retrieved",
128///   "data": {
129///     "submissions": [
130///       {
131///         "id": 5512,
132///         "status": "submitted",
133///         "score": { "earned": 42, "total": 50 },
134///         "created_at": "2025-08-08T12:15:00Z",
135///         "updated_at": "2025-08-08T12:15:00Z",
136///         "is_late": false,
137///         "module": { "id": 344, "code": "COS344" },
138///         "assignment": { "id": 102, "name": "A2 — Runtime & VM", "description": "..." },
139///         "user": { "id": 2511, "username": "u23571561" }
140///       }
141///     ],
142///     "page": 1,
143///     "per_page": 20,
144///     "total": 93
145///   }
146/// }
147/// ```
148///
149/// ### Error Responses
150/// - `400 Bad Request`: Invalid query parameters.
151/// - `401 Unauthorized`: Not logged in.
152pub async fn get_my_submissions(
153    State(app_state): State<AppState>,
154    Extension(user): Extension<AuthUser>,
155    Query(query): Query<GetSubmissionsQuery>,
156) -> impl IntoResponse {
157    if let Err(e) = query.validate() {
158        return (
159            StatusCode::BAD_REQUEST,
160            Json(ApiResponse::<()>::error(format_validation_errors(&e))),
161        )
162            .into_response();
163    }
164
165    let db = app_state.db();
166    let caller_id = user.0.sub;
167
168    let page = query.page.unwrap_or(1);
169    let per_page = query.per_page.unwrap_or(20);
170
171    let mut query_builder = SubmissionEntity::find()
172        .column_as(SubmissionColumn::Id, "id")
173        .column_as(SubmissionColumn::Earned, "earned")
174        .column_as(SubmissionColumn::Total, "total")
175        .column_as(SubmissionColumn::CreatedAt, "created_at")
176        .column_as(SubmissionColumn::UpdatedAt, "updated_at")
177        .column_as(SubmissionColumn::UserId, "user_id")
178        .column_as(SubmissionColumn::AssignmentId, "assignment_id")
179        .column_as(assignment::Column::Name, "assignment_name")
180        .column_as(assignment::Column::Description, "assignment_description")
181        .column_as(assignment::Column::DueDate, "assignment_due_date")
182        .column_as(module::Column::Id, "module_id")
183        .column_as(module::Column::Code, "module_code")
184        .column_as(user::Column::Username, "username")
185        .join(
186            sea_orm::JoinType::InnerJoin,
187            assignment_submission::Relation::Assignment.def(),
188        )
189        .join(
190            sea_orm::JoinType::InnerJoin,
191            assignment::Relation::Module.def(),
192        )
193        .join(
194            sea_orm::JoinType::InnerJoin,
195            assignment_submission::Relation::User.def(),
196        )
197        .join(
198            sea_orm::JoinType::InnerJoin,
199            module::Relation::UserModuleRole.def(),
200        )
201        .filter(user_module_role::Column::UserId.eq(caller_id));
202
203    let role_to_check = query.role.unwrap_or(Role::Student);
204
205    match role_to_check {
206        Role::Student => {
207            query_builder = query_builder.filter(SubmissionColumn::UserId.eq(caller_id));
208        }
209        Role::Lecturer | Role::AssistantLecturer | Role::Tutor => {
210            query_builder = query_builder.filter(RoleColumn::Role.eq(role_to_check));
211        }
212    }
213
214    let mut condition = Condition::all();
215    
216    if let Some(q) = &query.query {
217        let pattern = format!("%{}%", q.to_lowercase());
218        condition = condition.add(
219            Condition::any()
220                .add(module::Column::Code.like(&pattern))
221                .add(user::Column::Username.like(&pattern))
222                .add(assignment::Column::Name.like(&pattern)),
223        );
224    }
225
226    if let Some(year) = query.year {
227        condition = condition.add(module::Column::Year.eq(year));
228    }
229
230    if let Some(is_late) = query.is_late {
231        let submission_alias = Alias::new("assignment_submissions");
232        let assignment_alias = Alias::new("assignments");
233        
234        let created_at = Expr::col((submission_alias.clone(), SubmissionColumn::CreatedAt));
235        let due_date = Expr::col((assignment_alias.clone(), assignment::Column::DueDate));
236
237        if is_late {
238            condition = condition.add(created_at.gt(due_date));
239        } else {
240            condition = condition.add(created_at.lte(due_date));
241        }
242    }
243
244    query_builder = query_builder.filter(condition);
245
246    if let Some(sort) = &query.sort {
247        for s in sort.split(',') {
248            let (field, order) = if s.starts_with('-') {
249                (&s[1..], sea_orm::Order::Desc)
250            } else {
251                (s, sea_orm::Order::Asc)
252            };
253
254            match field {
255                "score" => {
256                    query_builder = query_builder.order_by(
257                        sea_orm::prelude::Expr::cust(
258                            "COALESCE((earned * 1.0) / NULLIF(total, 0), 0)",
259                        ),
260                        order,
261                    );
262                }
263                "created_at" => {
264                    query_builder = query_builder.order_by(SubmissionColumn::CreatedAt, order);
265                }
266                _ => {}
267            }
268        }
269    } else {
270        query_builder = query_builder.order_by(SubmissionColumn::CreatedAt, sea_orm::Order::Desc);
271    }
272    
273    query_builder = query_builder.order_by(SubmissionColumn::Id, sea_orm::Order::Asc);
274
275    let paginator = query_builder
276        .into_model::<SubmissionWithRelations>()
277        .paginate(db, per_page);
278    
279    let total = paginator.num_items().await.unwrap_or(0);
280    let submissions_db: Vec<SubmissionWithRelations> = paginator
281        .fetch_page(page - 1)
282        .await
283        .unwrap_or_default();
284
285    let submissions: Vec<SubmissionItem> = submissions_db
286        .into_iter()
287        .map(|s| {
288            let is_late = s.created_at > s.assignment_due_date;
289
290            SubmissionItem {
291                id: s.id,
292                status: "submitted".to_string(),
293                score: Score {
294                    earned: s.earned,
295                    total: s.total,
296                },
297                created_at: s.created_at.to_string(),
298                updated_at: s.updated_at.to_string(),
299                is_late,
300                module: ModuleInfo {
301                    id: s.module_id,
302                    code: s.module_code,
303                },
304                assignment: AssignmentInfo {
305                    id: s.assignment_id,
306                    name: s.assignment_name,
307                    description: s.assignment_description,
308                },
309                user: UserInfo {
310                    id: s.user_id,
311                    username: s.username,
312                },
313            }
314        })
315        .collect();
316
317    if submissions.is_empty() {
318        return (
319            StatusCode::OK,
320            Json(ApiResponse::success(
321                GetSubmissionsResponse {
322                    submissions: vec![],
323                    page,
324                    per_page,
325                    total: 0,
326                },
327                "No submissions found",
328            )),
329        ).into_response();
330    }
331
332    (
333        StatusCode::OK,
334        Json(ApiResponse::success(
335            GetSubmissionsResponse {
336                submissions,
337                page,
338                per_page,
339                total,
340            },
341            "Submissions retrieved",
342        )),
343    )
344        .into_response()
345}