Skip to main content

headless_lms_models/
research_forms.rs

1use futures::Stream;
2use utoipa::ToSchema;
3
4use crate::prelude::*;
5
6#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
7
8pub struct ResearchForm {
9    pub id: Uuid,
10    pub course_id: Uuid,
11    pub content: serde_json::Value,
12    pub created_at: DateTime<Utc>,
13    pub updated_at: DateTime<Utc>,
14    pub deleted_at: Option<DateTime<Utc>>,
15}
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
18
19pub struct NewResearchForm {
20    pub course_id: Uuid,
21    pub content: serde_json::Value,
22}
23
24#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
25
26pub struct ResearchFormQuestion {
27    pub id: Uuid,
28    pub course_id: Uuid,
29    pub research_consent_form_id: Uuid,
30    pub question: String,
31    pub created_at: DateTime<Utc>,
32    pub updated_at: DateTime<Utc>,
33    pub deleted_at: Option<DateTime<Utc>>,
34}
35
36#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
37
38pub struct NewResearchFormQuestionAnswer {
39    pub user_id: Uuid,
40    pub research_form_question_id: Uuid,
41    pub research_consent: bool,
42}
43
44#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
45
46pub struct ResearchFormQuestionAnswer {
47    pub id: Uuid,
48    pub user_id: Uuid,
49    pub course_id: Uuid,
50    pub research_form_question_id: Uuid,
51    pub research_consent: bool,
52    pub created_at: DateTime<Utc>,
53    pub updated_at: DateTime<Utc>,
54    pub deleted_at: Option<DateTime<Utc>>,
55}
56
57impl NewResearchForm {
58    /// Creates `NewResearchForm` with provided values that is public by default.
59    pub fn new(course_id: Uuid) -> Self {
60        Self {
61            course_id,
62            content: Default::default(),
63        }
64    }
65
66    /// Sets the content of this research form.
67    pub fn set_content(mut self, content: serde_json::Value) -> Self {
68        self.content = content;
69        self
70    }
71}
72
73pub async fn upsert_research_form(
74    conn: &mut PgConnection,
75    pkey_policy: PKeyPolicy<Uuid>,
76    new_research_form: &NewResearchForm,
77) -> ModelResult<ResearchForm> {
78    let form_res = sqlx::query_as!(
79        ResearchForm,
80        "
81INSERT INTO course_specific_research_consent_forms (
82    id,
83    course_id,
84    content
85  )
86VALUES ($1, $2, $3) ON CONFLICT (course_id, deleted_at)
87DO UPDATE SET content = $3
88RETURNING *
89",
90        pkey_policy.into_uuid(),
91        new_research_form.course_id,
92        serde_json::to_value(new_research_form.content.clone())?,
93    )
94    .fetch_one(conn)
95    .await?;
96    Ok(form_res)
97}
98
99pub async fn get_research_form_with_course_id(
100    conn: &mut PgConnection,
101    course_id: Uuid,
102) -> ModelResult<ResearchForm> {
103    let form_res = sqlx::query_as!(
104        ResearchForm,
105        "
106SELECT * FROM course_specific_research_consent_forms
107WHERE course_id = $1
108AND deleted_at IS NULL
109",
110        course_id,
111    )
112    .fetch_one(conn)
113    .await?;
114    Ok(form_res)
115}
116
117/// Saves a course's research consent form and makes its question rows match the question
118/// blocks in `content`: questions whose block was removed are soft-deleted.
119///
120/// Errors with `InvalidRequest` if the content has no questions, a question is empty, or a
121/// question id belongs to another course.
122pub async fn save_research_form(
123    conn: &mut PgConnection,
124    course_id: Uuid,
125    content: serde_json::Value,
126) -> ModelResult<ResearchForm> {
127    let questions = questions_in_content(&content)?;
128    if questions.is_empty() {
129        return Err(model_err!(
130            InvalidRequest,
131            "A research form needs at least one question".to_string()
132        ));
133    }
134
135    let mut tx = conn.begin().await?;
136    let form = upsert_research_form(
137        &mut tx,
138        PKeyPolicy::Generate,
139        &NewResearchForm { course_id, content },
140    )
141    .await?;
142
143    for (question_id, question) in &questions {
144        // The WHERE stops a save from taking over a question id that belongs to another course.
145        sqlx::query!(
146            "
147INSERT INTO course_specific_consent_form_questions (
148    id,
149    course_id,
150    research_consent_form_id,
151    question
152  )
153VALUES ($1, $2, $3, $4) ON CONFLICT (id)
154DO UPDATE SET question = $4,
155research_consent_form_id = $3,
156deleted_at = NULL
157WHERE course_specific_consent_form_questions.course_id = excluded.course_id
158RETURNING id
159",
160            question_id,
161            course_id,
162            form.id,
163            question
164        )
165        .fetch_optional(&mut *tx)
166        .await?
167        .ok_or_else(|| {
168            model_err!(
169                InvalidRequest,
170                "Question id belongs to a different course".to_string()
171            )
172        })?;
173    }
174
175    let question_ids: Vec<Uuid> = questions.iter().map(|(id, _)| *id).collect();
176    sqlx::query!(
177        "
178UPDATE course_specific_consent_form_questions
179SET deleted_at = NOW()
180WHERE research_consent_form_id = $1
181  AND deleted_at IS NULL
182  AND id <> ALL($2::uuid [])
183",
184        form.id,
185        &question_ids
186    )
187    .execute(&mut *tx)
188    .await?;
189
190    tx.commit().await?;
191    Ok(form)
192}
193
194/// The `(id, question)` of each top-level question block; a block's clientId is its question id.
195fn questions_in_content(content: &serde_json::Value) -> ModelResult<Vec<(Uuid, String)>> {
196    let blocks = content.as_array().map(Vec::as_slice).unwrap_or_default();
197    blocks
198        .iter()
199        .filter(|block| block["name"] == "moocfi/research-consent-question")
200        .map(|block| {
201            let id = block["clientId"]
202                .as_str()
203                .and_then(|id| Uuid::parse_str(id).ok())
204                .ok_or_else(|| {
205                    model_err!(InvalidRequest, "Question has no valid id".to_string())
206                })?;
207            let question = block["attributes"]["content"]
208                .as_str()
209                .unwrap_or_default()
210                .trim()
211                .to_string();
212            if question.is_empty() {
213                return Err(model_err!(
214                    InvalidRequest,
215                    "Research form questions cannot be empty".to_string()
216                ));
217            }
218            Ok((id, question))
219        })
220        .collect()
221}
222
223pub async fn get_research_form_questions_with_course_id(
224    conn: &mut PgConnection,
225    course_id: Uuid,
226) -> ModelResult<Vec<ResearchFormQuestion>> {
227    let form_res = sqlx::query_as!(
228        ResearchFormQuestion,
229        "
230SELECT * FROM course_specific_consent_form_questions
231WHERE course_id = $1
232AND deleted_at IS NULL
233",
234        course_id,
235    )
236    .fetch_all(conn)
237    .await?;
238    Ok(form_res)
239}
240
241pub async fn get_question_by_id(
242    conn: &mut PgConnection,
243    question_id: Uuid,
244) -> ModelResult<ResearchFormQuestion> {
245    let res = sqlx::query_as!(
246        ResearchFormQuestion,
247        "
248SELECT *
249FROM course_specific_consent_form_questions
250WHERE id = $1
251  AND deleted_at IS NULL
252        ",
253        question_id
254    )
255    .fetch_one(conn)
256    .await?;
257    Ok(res)
258}
259
260pub struct ExportedCourseResearchFormQustionAnswer {
261    pub course_id: Uuid,
262    pub research_consent_form_id: Uuid,
263    pub research_form_question_id: Uuid,
264    pub question: String,
265    pub user_id: Uuid,
266    pub research_consent: bool,
267    pub created_at: DateTime<Utc>,
268    pub updated_at: DateTime<Utc>,
269}
270
271pub fn stream_course_research_form_user_answers(
272    conn: &mut PgConnection,
273    course_id: Uuid,
274) -> impl Stream<Item = sqlx::Result<ExportedCourseResearchFormQustionAnswer>> + '_ {
275    sqlx::query_as!(
276        ExportedCourseResearchFormQustionAnswer,
277        r#"
278    SELECT DISTINCT ON (a.research_form_question_id, a.user_id)
279        q.course_id,
280        q.research_consent_form_id,
281        a.research_form_question_id,
282        q.question,
283        a.user_id,
284        a.research_consent,
285        a.created_at,
286        a.updated_at
287        FROM course_specific_consent_form_answers a
288    LEFT JOIN course_specific_consent_form_questions q ON a.research_form_question_id = q.id
289    WHERE a.course_id = $1
290    AND a.deleted_at IS NULL
291    AND q.deleted_at IS NULL
292    ORDER BY a.user_id, a.research_form_question_id, a.updated_at DESC
293    "#,
294        course_id
295    )
296    .fetch(conn)
297}
298
299pub async fn upsert_research_form_anwser(
300    conn: &mut PgConnection,
301    course_id: Uuid,
302    answer: &NewResearchFormQuestionAnswer,
303) -> ModelResult<Uuid> {
304    let form_res = sqlx::query!(
305        "
306INSERT INTO course_specific_consent_form_answers (
307    user_id,
308    course_id,
309    research_form_question_id,
310    research_consent
311  )
312VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, research_form_question_id)
313DO UPDATE SET research_consent = $4
314RETURNING *
315",
316        answer.user_id,
317        course_id,
318        answer.research_form_question_id,
319        answer.research_consent
320    )
321    .fetch_one(conn)
322    .await?;
323    Ok(form_res.id)
324}
325
326pub async fn upsert_answer_for_user_id_and_question_id(
327    conn: &mut PgConnection,
328    user_id: Uuid,
329    course_id: Uuid,
330    question_id: Uuid,
331    research_consent: bool,
332) -> ModelResult<Uuid> {
333    let res = sqlx::query!(
334        "
335INSERT INTO course_specific_consent_form_answers (
336    user_id,
337    course_id,
338    research_form_question_id,
339    research_consent
340)
341SELECT $1, q.course_id, q.id, $4
342FROM course_specific_consent_form_questions q
343WHERE q.id = $2
344  AND q.course_id = $3
345  AND q.deleted_at IS NULL
346ON CONFLICT (user_id, research_form_question_id)
347DO UPDATE SET
348  course_id = excluded.course_id,
349  research_consent = excluded.research_consent,
350  deleted_at = NULL
351RETURNING id
352        ",
353        user_id,
354        question_id,
355        course_id,
356        research_consent
357    )
358    .fetch_one(conn)
359    .await?;
360    Ok(res.id)
361}
362
363pub async fn get_research_form_answers_with_user_id(
364    conn: &mut PgConnection,
365    course_id: Uuid,
366    user_id: Uuid,
367) -> ModelResult<Vec<ResearchFormQuestionAnswer>> {
368    let form_res = sqlx::query_as!(
369        ResearchFormQuestionAnswer,
370        "
371SELECT * FROM course_specific_consent_form_answers
372WHERE course_id = $1 AND user_id = $2
373AND deleted_at IS NULL
374",
375        course_id,
376        user_id
377    )
378    .fetch_all(conn)
379    .await?;
380    Ok(form_res)
381}
382
383pub async fn get_all_research_form_answers_with_user_id(
384    conn: &mut PgConnection,
385    user_id: Uuid,
386) -> ModelResult<Vec<ResearchFormQuestionAnswer>> {
387    let form_res = sqlx::query_as!(
388        ResearchFormQuestionAnswer,
389        "
390SELECT * FROM course_specific_consent_form_answers
391WHERE user_id = $1
392AND deleted_at IS NULL
393",
394        user_id
395    )
396    .fetch_all(conn)
397    .await?;
398    Ok(form_res)
399}
400
401pub async fn get_all_research_form_answers_with_user_course_and_question_id(
402    conn: &mut PgConnection,
403    user_id: Uuid,
404    course_id: Uuid,
405    course_specific_consent_form_question_id: Uuid,
406) -> ModelResult<Vec<ResearchFormQuestionAnswer>> {
407    let form_res = sqlx::query_as!(
408        ResearchFormQuestionAnswer,
409        "
410SELECT * FROM course_specific_consent_form_answers
411WHERE user_id = $1
412AND course_id = $2
413AND research_form_question_id = $3
414AND deleted_at IS NULL
415",
416        user_id,
417        course_id,
418        course_specific_consent_form_question_id
419    )
420    .fetch_all(conn)
421    .await?;
422    Ok(form_res)
423}