Skip to main content

headless_lms_models/
feedback.rs

1use crate::{
2    feedback_categories::{self, NewFeedbackCategory},
3    prelude::*,
4};
5use utoipa::ToSchema;
6
7#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
8
9pub struct NewFeedback {
10    pub feedback_given: String,
11    pub selected_text: Option<String>,
12    pub related_blocks: Vec<FeedbackBlock>,
13    pub page_id: Uuid,
14    pub category: Option<NewFeedbackCategory>,
15}
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
18
19pub struct FeedbackBlock {
20    pub id: Uuid,
21    pub text: Option<String>,
22    pub order_number: Option<i32>,
23}
24
25#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
26pub struct FeedbackRow {
27    pub id: Uuid,
28    pub created_at: DateTime<Utc>,
29    pub updated_at: DateTime<Utc>,
30    pub deleted_at: Option<DateTime<Utc>>,
31    pub user_id: Option<Uuid>,
32    pub course_id: Option<Uuid>,
33    pub exam_id: Option<Uuid>,
34    pub page_id: Option<Uuid>,
35    pub feedback_given: String,
36    pub selected_text: Option<String>,
37    pub marked_as_read: bool,
38    pub category_id: Option<Uuid>,
39}
40
41pub async fn insert(
42    conn: &mut PgConnection,
43    pkey_policy: PKeyPolicy<Uuid>,
44    user_id: Option<Uuid>,
45    course_id: Uuid,
46    new_feedback: NewFeedback,
47) -> ModelResult<Uuid> {
48    let category_id = if let Some(category) = new_feedback.category {
49        // if inserting the category fails for some reason, let's still try to insert
50        // the feedback.
51        feedback_categories::insert_or_get_by_name(&mut *conn, category)
52            .await
53            .inspect_err(|e| error!("Error while inserting new feedback cateogry: {e}"))
54            .ok()
55    } else {
56        None
57    };
58    let mut tx = conn.begin().await?;
59
60    let res = sqlx::query!(
61        "
62INSERT INTO feedback(
63    id,
64    user_id,
65    course_id,
66    feedback_given,
67    selected_text,
68    page_id,
69    category_id
70  )
71VALUES ($1, $2, $3, $4, $5, $6, $7)
72RETURNING *
73        ",
74        pkey_policy.into_uuid(),
75        user_id,
76        course_id,
77        new_feedback.feedback_given,
78        new_feedback.selected_text,
79        new_feedback.page_id,
80        category_id
81    )
82    .fetch_one(&mut *tx)
83    .await?;
84    for (n, block) in new_feedback.related_blocks.iter().enumerate() {
85        sqlx::query!(
86            "
87INSERT INTO block_feedback(feedback_id, block_id, block_text, order_number)
88VALUES ($1, $2, $3, $4)
89",
90            res.id,
91            block.id,
92            block.text,
93            n as i32
94        )
95        .execute(&mut *tx)
96        .await?;
97    }
98    tx.commit().await?;
99    Ok(res.id)
100}
101
102pub async fn mark_as_read(conn: &mut PgConnection, id: Uuid, read: bool) -> ModelResult<()> {
103    sqlx::query!(
104        "
105UPDATE feedback
106SET marked_as_read = $1
107WHERE id = $2
108",
109        read,
110        id
111    )
112    .execute(conn)
113    .await?;
114    Ok(())
115}
116
117pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<FeedbackRow> {
118    let res = sqlx::query_as!(
119        FeedbackRow,
120        r#"
121SELECT *
122FROM feedback
123WHERE id = $1
124  AND deleted_at IS NULL
125        "#,
126        id
127    )
128    .fetch_one(conn)
129    .await?;
130    Ok(res)
131}
132
133pub async fn set_read_state_by_id_and_course_id(
134    conn: &mut PgConnection,
135    id: Uuid,
136    course_id: Uuid,
137    read: bool,
138) -> ModelResult<FeedbackRow> {
139    let res = sqlx::query_as!(
140        FeedbackRow,
141        r#"
142UPDATE feedback
143SET marked_as_read = $3
144WHERE id = $1
145  AND course_id = $2
146  AND deleted_at IS NULL
147RETURNING *
148        "#,
149        id,
150        course_id,
151        read
152    )
153    .fetch_one(conn)
154    .await?;
155    Ok(res)
156}
157
158#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
159
160pub struct Feedback {
161    pub id: Uuid,
162    pub user_id: Option<Uuid>,
163    pub course_id: Uuid,
164    pub page_id: Option<Uuid>,
165    pub feedback_given: String,
166    pub selected_text: Option<String>,
167    pub marked_as_read: bool,
168    pub created_at: DateTime<Utc>,
169    pub blocks: Vec<FeedbackBlock>,
170    pub page_title: String,
171    pub page_url_path: String,
172    pub feedback_category_name: Option<String>,
173    pub feedback_category_id: Option<Uuid>,
174}
175
176pub async fn get_feedback_for_course(
177    conn: &mut PgConnection,
178    course_id: Uuid,
179    category_filter: Option<Uuid>,
180    read: bool,
181    pagination: Pagination,
182) -> ModelResult<Vec<Feedback>> {
183    let res = sqlx::query!(
184        r#"
185SELECT fb.*,
186  pages.title AS "page_title",
187  pages.url_path AS "page_url_path"
188FROM (
189    SELECT feedback.id AS "id!",
190      feedback.user_id,
191      feedback.course_id AS "course_id!",
192      feedback.page_id,
193      feedback.feedback_given AS "feedback_given!",
194      feedback.selected_text,
195      feedback.marked_as_read AS "marked_as_read!",
196      feedback.created_at AS "created_at!",
197      feedback.category_id,
198      feedback_categories.name AS "feedback_category_name: Option<String>",
199      array_agg(block_feedback.block_id) filter (
200        WHERE block_feedback.block_id IS NOT NULL
201      ) AS "block_ids: Vec<Uuid>",
202      array_agg(block_feedback.block_text) filter (
203        WHERE block_feedback.block_id IS NOT NULL
204      ) AS "block_texts: Vec<Option<String>>",
205      array_agg(block_feedback.order_number) filter (
206        WHERE block_feedback.block_id IS NOT NULL
207      ) AS "block_order_numbers: Vec<Option<i32>>"
208    FROM feedback
209      LEFT JOIN block_feedback ON block_feedback.feedback_id = feedback.id
210      LEFT JOIN feedback_categories ON feedback_categories.id = feedback.category_id
211      AND feedback_categories.deleted_at IS NULL
212    WHERE course_id = $1
213      AND feedback.marked_as_read = $2
214      AND feedback.deleted_at IS NULL
215      AND block_feedback.deleted_at IS NULL
216      AND (
217        CASE
218          WHEN $5::uuid IS NOT NULL
219            THEN feedback_categories.id = $5::uuid
220            ELSE TRUE
221        END
222      )
223    GROUP BY feedback.id,
224      feedback.user_id,
225      feedback.course_id,
226      feedback.feedback_given,
227      feedback.marked_as_read,
228      feedback.created_at,
229      feedback_categories.name
230    ORDER BY feedback.created_at DESC,
231      feedback.id
232    LIMIT $3 OFFSET $4
233  ) fb
234  JOIN pages ON pages.id = fb.page_id
235ORDER BY fb."created_at!" DESC,
236  fb."id!"
237        "#,
238        course_id,
239        read,
240        pagination.limit(),
241        pagination.offset(),
242        category_filter as Option<Uuid>
243    )
244    .map(|r| Feedback {
245        id: r.id,
246        user_id: r.user_id,
247        course_id: r.course_id,
248        page_id: r.page_id,
249        feedback_given: r.feedback_given,
250        selected_text: r.selected_text,
251        marked_as_read: r.marked_as_read,
252        created_at: r.created_at,
253        blocks: r
254            .block_ids
255            .unwrap_or_default()
256            .into_iter()
257            .zip(r.block_texts.unwrap_or_default())
258            .zip(r.block_order_numbers.unwrap_or_default())
259            .map(|((id, text), order_number)| FeedbackBlock {
260                id,
261                text,
262                order_number,
263            })
264            .collect(),
265        page_title: r.page_title,
266        page_url_path: r.page_url_path,
267        feedback_category_name: r.feedback_category_name,
268        feedback_category_id: r.category_id,
269    })
270    .fetch_all(conn)
271    .await?;
272    Ok(res)
273}
274
275#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
276
277pub struct FeedbackCount {
278    pub read: u32,
279    pub unread: u32,
280    pub categories: Vec<CategoryFeedbackCount>,
281}
282
283#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, ToSchema)]
284pub struct CategoryFeedbackCount {
285    pub category_id: Uuid,
286    pub read_feedback: u32,
287    pub unread_feedback: u32,
288}
289
290pub async fn get_feedback_count_for_course(
291    conn: &mut PgConnection,
292    course_id: Uuid,
293) -> ModelResult<FeedbackCount> {
294    let res_categories = sqlx::query!(
295        r#"
296SELECT COUNT(*) filter (
297    WHERE marked_as_read
298  ) AS READ,
299  COUNT(*) filter (
300    WHERE NOT(marked_as_read)
301  ) AS unread,
302  category_id
303FROM feedback
304WHERE course_id = $1
305  AND deleted_at IS NULL
306GROUP BY category_id
307        "#,
308        course_id
309    )
310    .fetch_all(&mut *conn)
311    .await?;
312
313    let mut all_read = 0;
314    let mut all_unread = 0;
315
316    let c: Vec<CategoryFeedbackCount> = res_categories
317        .iter()
318        .filter_map(|x| {
319            let read = x.read.unwrap_or_default().try_into().ok()?;
320            let unread = x.unread.unwrap_or_default().try_into().ok()?;
321            all_read += read;
322            all_unread += unread;
323            Some(CategoryFeedbackCount {
324                category_id: x.category_id?,
325                read_feedback: read,
326                unread_feedback: unread,
327            })
328        })
329        .collect();
330
331    Ok(FeedbackCount {
332        read: all_read,
333        unread: all_unread,
334        categories: c,
335    })
336}
337
338/// Block-level feedback on the given pages of the course.
339pub async fn get_block_ids_by_page_ids(
340    conn: &mut PgConnection,
341    course_id: Uuid,
342    page_ids: &[Uuid],
343) -> ModelResult<Vec<crate::pages::PageBlockReferences>> {
344    let res = sqlx::query_as!(
345        crate::pages::PageBlockReferences,
346        r#"
347SELECT feedback.id,
348  feedback.page_id AS "page_id!",
349  array_agg(
350    block_feedback.block_id
351    ORDER BY block_feedback.order_number
352  ) AS "block_ids!"
353FROM feedback
354  JOIN block_feedback ON block_feedback.feedback_id = feedback.id
355  AND block_feedback.deleted_at IS NULL
356WHERE feedback.page_id = ANY($1)
357  AND feedback.course_id = $2
358  AND feedback.deleted_at IS NULL
359GROUP BY feedback.id
360        "#,
361        page_ids,
362        course_id
363    )
364    .fetch_all(conn)
365    .await?;
366    Ok(res)
367}
368
369/// Sets each feedback's page; `feedback_ids` and `page_ids` are parallel.
370pub async fn set_page_ids(
371    conn: &mut PgConnection,
372    feedback_ids: &[Uuid],
373    page_ids: &[Uuid],
374) -> ModelResult<()> {
375    sqlx::query!(
376        "
377UPDATE feedback
378SET page_id = moved.page_id
379FROM UNNEST($1::uuid [], $2::uuid []) AS moved(id, page_id)
380WHERE feedback.id = moved.id
381        ",
382        feedback_ids,
383        page_ids
384    )
385    .execute(conn)
386    .await?;
387    Ok(())
388}