1use crate::{
2 courses, exercise_slide_submissions, exercises, peer_review_queue_entries,
3 prelude::*,
4 user_exercise_states::{self, 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 let flagged_submission_data =
69 exercise_slide_submissions::get_by_id(&mut tx, flagged_answer.submission_id).await?;
70
71 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
82 let new_flagged_answer = NewFlaggedAnswer {
84 submission_id: flagged_answer.submission_id,
85 flagged_user: Some(flagged_user),
86 flagged_by: Some(giver_user_id),
87 reason: flagged_answer.reason,
88 description: flagged_answer.description.clone(),
89 };
90
91 let insert_result = insert_flagged_answer(&mut tx, new_flagged_answer).await?;
93
94 let updated_flag_count = increment_flag_count(&mut tx, flagged_answer.submission_id).await?;
96
97 let course = courses::get_course(&mut tx, course_id).await?;
99
100 if let Some(flagged_answers_threshold) = course.flagged_answers_threshold
103 && updated_flag_count >= flagged_answers_threshold
104 {
105 if course.flagged_answers_skip_manual_review_and_allow_retry {
106 let course_id = flagged_submission_data.course_id.ok_or_else(|| {
107 ModelError::new(
108 ModelErrorType::Generic,
109 "No course instance found for the submission.".to_string(),
110 None,
111 )
112 })?;
113
114 let _ = exercises::reset_exercises_for_selected_users(
115 &mut tx,
116 &[(flagged_user, vec![flagged_submission_data.exercise_id])],
117 None,
118 course_id,
119 Some("flagged-answers-skip-manual-review-and-allow-retry".to_string()),
120 )
121 .await?;
122 } else {
123 let course_id = flagged_submission_data
125 .course_id
126 .map(CourseOrExamId::Course)
127 .ok_or_else(|| {
128 ModelError::new(
129 ModelErrorType::Generic,
130 "No course instance found for the submission.".to_string(),
131 None,
132 )
133 })?;
134
135 let update_result = user_exercise_states::update_reviewing_stage(
137 &mut tx,
138 flagged_user,
139 course_id,
140 flagged_submission_data.exercise_id,
141 ReviewingStage::WaitingForManualGrading,
142 )
143 .await?;
144
145 if let Some(course_id) = update_result.course_id {
148 peer_review_queue_entries::remove_queue_entries_for_unusual_reason(
149 &mut tx,
150 flagged_user,
151 flagged_submission_data.exercise_id,
152 course_id,
153 )
154 .await?;
155 }
156 }
157 }
158 crate::offered_answers_to_peer_review_temporary::delete_saved_submissions_for_user(
160 &mut tx,
161 flagged_submission_data.exercise_id,
162 giver_user_id,
163 )
164 .await?;
165
166 tx.commit().await.map_err(|_| {
167 ModelError::new(
168 ModelErrorType::Generic,
169 "Failed to commit transaction".to_string(),
170 None,
171 )
172 })?;
173
174 Ok(insert_result)
175}
176
177pub async fn insert_flagged_answer(
178 conn: &mut PgConnection,
179 flagged_answer: NewFlaggedAnswer,
180) -> ModelResult<FlaggedAnswer> {
181 let res = sqlx::query_as!(
182 FlaggedAnswer,
183 r#"
184INSERT INTO flagged_answers (
185 submission_id,
186 flagged_user,
187 flagged_by,
188 reason,
189 description
190)
191VALUES ($1, $2, $3, $4, $5)
192RETURNING id,
193 submission_id,
194 flagged_user,
195 flagged_by,
196 reason AS "reason: _",
197 description,
198 created_at,
199 updated_at,
200 deleted_at
201 "#,
202 flagged_answer.submission_id,
203 flagged_answer.flagged_user,
204 flagged_answer.flagged_by,
205 flagged_answer.reason as ReportReason,
206 flagged_answer.description,
207 )
208 .fetch_one(conn)
209 .await?;
210 Ok(res)
211}
212
213pub async fn increment_flag_count(
214 conn: &mut PgConnection,
215 submission_id: Uuid,
216) -> ModelResult<i32> {
217 let result = sqlx::query!(
218 r#"
219 UPDATE exercise_slide_submissions
220 SET flag_count = COALESCE(flag_count, 0) + 1
221 WHERE id = $1
222 RETURNING flag_count
223 "#,
224 submission_id
225 )
226 .fetch_one(conn)
227 .await?;
228
229 Ok(result.flag_count)
230}
231
232pub async fn get_flagged_answers_by_submission_id(
233 conn: &mut PgConnection,
234 exercise_slide_submission_id: Uuid,
235) -> ModelResult<Vec<FlaggedAnswer>> {
236 let results = sqlx::query_as!(
237 FlaggedAnswer,
238 r#"
239 SELECT
240 id,
241 submission_id,
242 flagged_user,
243 flagged_by,
244 reason AS "reason: _",
245 description,
246 created_at,
247 updated_at,
248 deleted_at
249 FROM flagged_answers
250 WHERE submission_id = $1
251 AND deleted_at IS NULL
252 "#,
253 exercise_slide_submission_id
254 )
255 .fetch_all(conn)
256 .await?;
257
258 Ok(results)
259}
260
261pub async fn get_flagged_answers_submission_ids_by_flaggers_id(
262 conn: &mut PgConnection,
263 flagged_by: Uuid,
264) -> ModelResult<Vec<Uuid>> {
265 let flagged_submissions = sqlx::query_as!(
266 FlaggedAnswer,
267 r#"
268 SELECT
269 id,
270 submission_id,
271 flagged_user,
272 flagged_by,
273 reason AS "reason: _",
274 description,
275 created_at,
276 updated_at,
277 deleted_at
278 FROM flagged_answers
279 WHERE flagged_by = $1
280 AND deleted_at IS NULL
281 "#,
282 flagged_by
283 )
284 .fetch_all(conn)
285 .await?;
286
287 Ok(flagged_submissions
288 .into_iter()
289 .map(|row| row.submission_id)
290 .collect())
291}