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 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 println!(
82 "Serialized ReportReason::Spam: {:?}",
83 serde_json::to_string(&flagged_answer.reason)?
84 );
85
86 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 let insert_result = insert_flagged_answer(&mut tx, new_flagged_answer).await?;
97
98 let updated_flag_count = increment_flag_count(&mut tx, flagged_answer.submission_id).await?;
100
101 let course = courses::get_course(&mut tx, course_id).await?;
103
104 if let Some(flagged_answers_threshold) = course.flagged_answers_threshold {
107 if updated_flag_count >= flagged_answers_threshold {
108 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 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 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 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}