headless_lms_models/
flagged_answers.rs

1use crate::{
2    courses, exercise_slide_submissions, peer_review_queue_entries,
3    prelude::*,
4    user_exercise_states::{self, CourseInstanceOrExamId, ReviewingStage},
5};
6
7#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
8#[cfg_attr(feature = "ts_rs", derive(TS))]
9pub struct FlaggedAnswer {
10    pub id: Uuid,
11    pub submission_id: Uuid,
12    pub flagged_user: Uuid,
13    pub flagged_by: Uuid,
14    pub reason: ReportReason,
15    pub description: Option<String>,
16    pub created_at: DateTime<Utc>,
17    pub updated_at: DateTime<Utc>,
18    pub deleted_at: Option<DateTime<Utc>>,
19}
20
21#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type)]
22#[cfg_attr(feature = "ts_rs", derive(TS))]
23#[sqlx(type_name = "report_reason")]
24pub enum ReportReason {
25    #[sqlx(rename = "flagging-reason-spam")]
26    Spam,
27    #[sqlx(rename = "flagging-reason-harmful-content")]
28    HarmfulContent,
29    #[sqlx(rename = "flagging-reason-ai-generated")]
30    AiGenerated,
31}
32
33#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
34#[cfg_attr(feature = "ts_rs", derive(TS))]
35pub struct NewFlaggedAnswer {
36    pub submission_id: Uuid,
37    pub flagged_user: Option<Uuid>,
38    pub flagged_by: Option<Uuid>,
39    pub reason: ReportReason,
40    pub description: Option<String>,
41}
42
43#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
44#[cfg_attr(feature = "ts_rs", derive(TS))]
45pub struct NewFlaggedAnswerWithToken {
46    pub submission_id: Uuid,
47    pub flagged_user: Option<Uuid>,
48    pub flagged_by: Option<Uuid>,
49    pub reason: ReportReason,
50    pub description: Option<String>,
51    pub peer_or_self_review_config_id: Uuid,
52    pub token: String,
53}
54
55pub async fn insert_flagged_answer_and_move_to_manual_review_if_needed(
56    conn: &mut PgConnection,
57    flagged_answer: NewFlaggedAnswerWithToken,
58    giver_user_id: Uuid,
59) -> ModelResult<FlaggedAnswer> {
60    let mut tx = conn.begin().await.map_err(|_| {
61        ModelError::new(
62            ModelErrorType::Generic,
63            "Failed to start transaction".to_string(),
64            None,
65        )
66    })?;
67    // Fetch flagged submission data
68    let flagged_submission_data =
69        exercise_slide_submissions::get_by_id(&mut tx, flagged_answer.submission_id).await?;
70
71    // Ensure the submission is related to a course
72    let course_id = flagged_submission_data.course_id.ok_or_else(|| {
73        ModelError::new(
74            ModelErrorType::Generic,
75            "Course id not found for the submission.".to_string(),
76            None,
77        )
78    })?;
79
80    let flagged_user = flagged_submission_data.user_id;
81    println!(
82        "Serialized ReportReason::Spam: {:?}",
83        serde_json::to_string(&flagged_answer.reason)?
84    );
85
86    // Create a new flagged answer
87    let new_flagged_answer = NewFlaggedAnswer {
88        submission_id: flagged_answer.submission_id,
89        flagged_user: Some(flagged_user),
90        flagged_by: Some(giver_user_id),
91        reason: flagged_answer.reason,
92        description: flagged_answer.description.clone(),
93    };
94
95    // Insert the flagged answer into the database
96    let insert_result = insert_flagged_answer(&mut tx, new_flagged_answer).await?;
97
98    // Increment the flag count
99    let updated_flag_count = increment_flag_count(&mut tx, flagged_answer.submission_id).await?;
100
101    // Fetch the course data
102    let course = courses::get_course(&mut tx, course_id).await?;
103
104    // Check if the flag count exceeds the courses flagged answers threshold.
105    // If it does the move to manual review and remove from the peer review queue.
106    if let Some(flagged_answers_threshold) = course.flagged_answers_threshold {
107        if updated_flag_count >= flagged_answers_threshold {
108            // Ensure course instance ID exists
109            let course_instance_id = flagged_submission_data
110                .course_instance_id
111                .map(CourseInstanceOrExamId::Instance)
112                .ok_or_else(|| {
113                    ModelError::new(
114                        ModelErrorType::Generic,
115                        "No course instance found for the submission.".to_string(),
116                        None,
117                    )
118                })?;
119
120            // Move the answer to manual review
121            let update_result = user_exercise_states::update_reviewing_stage(
122                &mut tx,
123                flagged_user,
124                course_instance_id,
125                flagged_submission_data.exercise_id,
126                ReviewingStage::WaitingForManualGrading,
127            )
128            .await?;
129
130            // Remove from peer review queue so other students can't review an answers that is already in manual review
131            // Remove the answer from the peer review queue
132            if let Some(instance_id) = update_result.course_instance_id {
133                peer_review_queue_entries::remove_queue_entries_for_unusual_reason(
134                    &mut tx,
135                    flagged_user,
136                    flagged_submission_data.exercise_id,
137                    instance_id,
138                )
139                .await?;
140            }
141        }
142    }
143    // Make sure the user who flagged this is allowed to get a new answer to review
144    crate::offered_answers_to_peer_review_temporary::delete_saved_submissions_for_user(
145        &mut tx,
146        flagged_submission_data.exercise_id,
147        giver_user_id,
148    )
149    .await?;
150
151    tx.commit().await.map_err(|_| {
152        ModelError::new(
153            ModelErrorType::Generic,
154            "Failed to commit transaction".to_string(),
155            None,
156        )
157    })?;
158
159    Ok(insert_result)
160}
161
162pub async fn insert_flagged_answer(
163    conn: &mut PgConnection,
164    flagged_answer: NewFlaggedAnswer,
165) -> ModelResult<FlaggedAnswer> {
166    let res = sqlx::query_as!(
167        FlaggedAnswer,
168        r#"
169INSERT INTO flagged_answers (
170    submission_id,
171    flagged_user,
172    flagged_by,
173    reason,
174    description
175)
176VALUES ($1, $2, $3, $4, $5)
177RETURNING id,
178  submission_id,
179  flagged_user,
180  flagged_by,
181  reason AS "reason: _",
182  description,
183  created_at,
184  updated_at,
185  deleted_at
186        "#,
187        flagged_answer.submission_id,
188        flagged_answer.flagged_user,
189        flagged_answer.flagged_by,
190        flagged_answer.reason as ReportReason,
191        flagged_answer.description,
192    )
193    .fetch_one(conn)
194    .await?;
195    Ok(res)
196}
197
198pub async fn increment_flag_count(
199    conn: &mut PgConnection,
200    submission_id: Uuid,
201) -> ModelResult<i32> {
202    let result = sqlx::query!(
203        r#"
204        UPDATE exercise_slide_submissions
205        SET flag_count = COALESCE(flag_count, 0) + 1
206        WHERE id = $1
207        RETURNING flag_count
208        "#,
209        submission_id
210    )
211    .fetch_one(conn)
212    .await?;
213
214    Ok(result.flag_count)
215}
216
217pub async fn get_flagged_answers_by_submission_id(
218    conn: &mut PgConnection,
219    exercise_slide_submission_id: Uuid,
220) -> ModelResult<Vec<FlaggedAnswer>> {
221    let results = sqlx::query_as!(
222        FlaggedAnswer,
223        r#"
224        SELECT
225            id,
226            submission_id,
227            flagged_user,
228            flagged_by,
229            reason AS "reason: _",
230            description,
231            created_at,
232            updated_at,
233            deleted_at
234        FROM flagged_answers
235        WHERE submission_id = $1
236          AND deleted_at IS NULL
237        "#,
238        exercise_slide_submission_id
239    )
240    .fetch_all(conn)
241    .await?;
242
243    Ok(results)
244}
245
246pub async fn get_flagged_answers_submission_ids_by_flaggers_id(
247    conn: &mut PgConnection,
248    flagged_by: Uuid,
249) -> ModelResult<Vec<Uuid>> {
250    let flagged_submissions = sqlx::query_as!(
251        FlaggedAnswer,
252        r#"
253        SELECT
254            id,
255            submission_id,
256            flagged_user,
257            flagged_by,
258            reason AS "reason: _",
259            description,
260            created_at,
261            updated_at,
262            deleted_at
263        FROM flagged_answers
264        WHERE flagged_by = $1
265          AND deleted_at IS NULL
266        "#,
267        flagged_by
268    )
269    .fetch_all(conn)
270    .await?;
271
272    Ok(flagged_submissions
273        .into_iter()
274        .map(|row| row.submission_id)
275        .collect())
276}