Skip to main content

headless_lms_models/
exercise_slide_submission_shares.rs

1use crate::prelude::*;
2
3/// A shareable link to an existing exercise-slide submission. The `id` is the
4/// unguessable token used in the shareable URL; a viewer resolves the token back
5/// to the submission it points at.
6#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, sqlx::FromRow, utoipa::ToSchema)]
7pub struct ExerciseSlideSubmissionShare {
8    pub id: Uuid,
9    pub exercise_slide_submission_id: Uuid,
10    pub created_by: Uuid,
11    pub created_at: DateTime<Utc>,
12    pub updated_at: DateTime<Utc>,
13    pub deleted_at: Option<DateTime<Utc>>,
14}
15
16pub async fn insert(
17    conn: &mut PgConnection,
18    exercise_slide_submission_id: Uuid,
19    created_by: Uuid,
20) -> ModelResult<ExerciseSlideSubmissionShare> {
21    let row = sqlx::query_as!(
22        ExerciseSlideSubmissionShare,
23        r#"
24INSERT INTO exercise_slide_submission_shares (exercise_slide_submission_id, created_by)
25VALUES ($1, $2)
26RETURNING *
27        "#,
28        exercise_slide_submission_id,
29        created_by,
30    )
31    .fetch_one(conn)
32    .await?;
33    Ok(row)
34}
35
36pub async fn get_by_id(
37    conn: &mut PgConnection,
38    id: Uuid,
39) -> ModelResult<ExerciseSlideSubmissionShare> {
40    let row = sqlx::query_as!(
41        ExerciseSlideSubmissionShare,
42        r#"
43SELECT *
44FROM exercise_slide_submission_shares
45WHERE id = $1
46  AND deleted_at IS NULL
47        "#,
48        id,
49    )
50    .fetch_one(conn)
51    .await?;
52    Ok(row)
53}
54
55/// The shares the user has minted, newest first, so they can be reviewed and withdrawn.
56pub async fn list_by_creator(
57    conn: &mut PgConnection,
58    created_by: Uuid,
59) -> ModelResult<Vec<ExerciseSlideSubmissionShare>> {
60    let rows = sqlx::query_as!(
61        ExerciseSlideSubmissionShare,
62        r#"
63SELECT *
64FROM exercise_slide_submission_shares
65WHERE created_by = $1
66  AND deleted_at IS NULL
67ORDER BY created_at DESC, id DESC
68        "#,
69        created_by,
70    )
71    .fetch_all(conn)
72    .await?;
73    Ok(rows)
74}
75
76/// Withdraws a share, which [`get_by_id`] then stops resolving. Scoped to the creator, so the
77/// unguessable token is not itself authority to revoke — only to view.
78///
79/// `false` means there was no live share with that id belonging to this user.
80pub async fn revoke(conn: &mut PgConnection, id: Uuid, created_by: Uuid) -> ModelResult<bool> {
81    let revoked = sqlx::query_scalar!(
82        r#"
83UPDATE exercise_slide_submission_shares
84SET deleted_at = now()
85WHERE id = $1
86  AND created_by = $2
87  AND deleted_at IS NULL
88RETURNING id
89        "#,
90        id,
91        created_by,
92    )
93    .fetch_optional(conn)
94    .await?;
95    Ok(revoked.is_some())
96}
97
98/// Withdraws every live share of one submission at once — the operation a user actually wants
99/// after sharing the same answer more than once.
100pub async fn revoke_all_for_submission(
101    conn: &mut PgConnection,
102    exercise_slide_submission_id: Uuid,
103    created_by: Uuid,
104) -> ModelResult<u64> {
105    let revoked = sqlx::query!(
106        r#"
107UPDATE exercise_slide_submission_shares
108SET deleted_at = now()
109WHERE exercise_slide_submission_id = $1
110  AND created_by = $2
111  AND deleted_at IS NULL
112        "#,
113        exercise_slide_submission_id,
114        created_by,
115    )
116    .execute(conn)
117    .await?
118    .rows_affected();
119    Ok(revoked)
120}
121
122#[cfg(test)]
123mod test {
124    use super::*;
125    use crate::exercise_slide_submissions::{
126        NewExerciseSlideSubmission, insert_exercise_slide_submission,
127    };
128    use crate::exercise_task_gradings::UserPointsUpdateStrategy;
129    use crate::test_helper::*;
130
131    async fn insert_submission(
132        tx: &mut PgConnection,
133        course_id: Uuid,
134        user_id: Uuid,
135        exercise_id: Uuid,
136        exercise_slide_id: Uuid,
137    ) -> Uuid {
138        insert_exercise_slide_submission(
139            tx,
140            NewExerciseSlideSubmission {
141                exercise_slide_id,
142                course_id: Some(course_id),
143                exam_id: None,
144                user_id,
145                exercise_id,
146                user_points_update_strategy:
147                    UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
148            },
149        )
150        .await
151        .unwrap()
152        .id
153    }
154
155    /// A share link is forwardable and permanent unless its creator can withdraw it, which is the
156    /// whole point of the soft-delete column.
157    #[tokio::test]
158    async fn a_creator_can_revoke_a_share_and_it_stops_resolving() {
159        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:_task);
160        let submission_id =
161            insert_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
162        let share = insert(tx.as_mut(), submission_id, user_id).await.unwrap();
163
164        assert_eq!(get_by_id(tx.as_mut(), share.id).await.unwrap().id, share.id);
165        assert_eq!(
166            list_by_creator(tx.as_mut(), user_id)
167                .await
168                .unwrap()
169                .iter()
170                .map(|s| s.id)
171                .collect::<Vec<_>>(),
172            vec![share.id]
173        );
174
175        assert!(revoke(tx.as_mut(), share.id, user_id).await.unwrap());
176        assert!(
177            get_by_id(tx.as_mut(), share.id).await.is_err(),
178            "a revoked share must no longer resolve"
179        );
180        assert!(
181            list_by_creator(tx.as_mut(), user_id)
182                .await
183                .unwrap()
184                .is_empty()
185        );
186        assert!(
187            !revoke(tx.as_mut(), share.id, user_id).await.unwrap(),
188            "revoking twice must be a no-op, not a second success"
189        );
190        tx.rollback().await;
191    }
192
193    /// Holding the token is authority to view, not to withdraw.
194    #[tokio::test]
195    async fn another_user_cannot_revoke_someone_elses_share() {
196        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:_task);
197        let other_user = crate::users::insert(
198            tx.as_mut(),
199            PKeyPolicy::Generate,
200            "share-thief@example.com",
201            None,
202            None,
203        )
204        .await
205        .unwrap();
206        let submission_id =
207            insert_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
208        let share = insert(tx.as_mut(), submission_id, user_id).await.unwrap();
209
210        assert!(!revoke(tx.as_mut(), share.id, other_user).await.unwrap());
211        assert_eq!(get_by_id(tx.as_mut(), share.id).await.unwrap().id, share.id);
212        tx.rollback().await;
213    }
214
215    /// Sharing is not idempotent, so withdrawing one link would otherwise leave the others live.
216    #[tokio::test]
217    async fn revoking_by_submission_withdraws_every_share_of_it() {
218        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:_task);
219        let submission_id =
220            insert_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
221        let other_submission =
222            insert_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
223        let first = insert(tx.as_mut(), submission_id, user_id).await.unwrap();
224        let second = insert(tx.as_mut(), submission_id, user_id).await.unwrap();
225        let unrelated = insert(tx.as_mut(), other_submission, user_id)
226            .await
227            .unwrap();
228
229        assert_eq!(
230            revoke_all_for_submission(tx.as_mut(), submission_id, user_id)
231                .await
232                .unwrap(),
233            2
234        );
235        assert!(get_by_id(tx.as_mut(), first.id).await.is_err());
236        assert!(get_by_id(tx.as_mut(), second.id).await.is_err());
237        assert_eq!(
238            get_by_id(tx.as_mut(), unrelated.id).await.unwrap().id,
239            unrelated.id,
240            "another submission's share must be untouched"
241        );
242        tx.rollback().await;
243    }
244}