Skip to main content

headless_lms_models/
agent_changesets.rs

1//! Agent changesets: one user's unpublished agent changes in one course.
2
3use chrono::Duration;
4use utoipa::ToSchema;
5
6use crate::{error::missing_model_error, prelude::*};
7
8/// How long an open changeset lives after its last write.
9const LIFETIME_AFTER_LAST_WRITE_DAYS: i64 = 14;
10
11/// How early before expiry results and the review page start warning about it.
12const EXPIRY_WARNING_DAYS: i64 = 3;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, ToSchema)]
15#[sqlx(type_name = "agent_changeset_status", rename_all = "snake_case")]
16#[serde(rename_all = "snake_case")]
17pub enum AgentChangesetStatus {
18    Open,
19    Published,
20    Discarded,
21    Expired,
22}
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
25pub struct AgentChangeset {
26    pub id: Uuid,
27    pub created_at: DateTime<Utc>,
28    pub updated_at: DateTime<Utc>,
29    /// The only user who can publish or discard the changeset.
30    pub user_id: Uuid,
31    pub course_id: Uuid,
32    /// The status now: `Expired` for an open changeset past `expires_at`, which the table records
33    /// only at the next write to the course; see [`AgentChangesetStatus::at`].
34    pub status: AgentChangesetStatus,
35    pub expires_at: DateTime<Utc>,
36    pub published_at: Option<DateTime<Utc>>,
37    /// Incremented by every change to the staged entities; see [`increment_revision`].
38    pub revision: i32,
39}
40
41impl AgentChangesetStatus {
42    /// The status at `now` of a changeset stored with this status: an open one past `expires_at`
43    /// has expired, though the table records that only at the next write to the course.
44    pub fn at(self, expires_at: DateTime<Utc>, now: DateTime<Utc>) -> Self {
45        if self == Self::Open && expires_at <= now {
46            Self::Expired
47        } else {
48            self
49        }
50    }
51}
52
53impl AgentChangeset {
54    /// The changeset as of now, from a row read as stored.
55    fn with_current_status(mut self) -> Self {
56        self.status = self.status.at(self.expires_at, Utc::now());
57        self
58    }
59
60    /// Whether the changeset can still take changes.
61    pub fn is_open(&self) -> bool {
62        self.status == AgentChangesetStatus::Open
63    }
64
65    /// Whether an open changeset is close enough to expiry that its owner should be warned.
66    pub fn is_expiring_soon(&self, now: DateTime<Utc>) -> bool {
67        self.is_open() && self.expires_at - now <= Duration::days(EXPIRY_WARNING_DAYS)
68    }
69}
70
71/// The user's open changeset for the course, created if there is none.
72pub async fn get_or_create_open(
73    conn: &mut PgConnection,
74    user_id: Uuid,
75    course_id: Uuid,
76) -> ModelResult<AgentChangeset> {
77    // An overdue changeset still holds the one open slot of the unique index until marked.
78    sqlx::query!(
79        r#"
80UPDATE agent_changesets
81SET status = 'expired'
82WHERE user_id = $1
83  AND course_id = $2
84  AND status = 'open'
85  AND expires_at <= now()
86        "#,
87        user_id,
88        course_id,
89    )
90    .execute(&mut *conn)
91    .await?;
92    sqlx::query!(
93        r#"
94INSERT INTO agent_changesets (user_id, course_id, expires_at)
95VALUES ($1, $2, now() + make_interval(days => $3))
96ON CONFLICT (user_id, course_id)
97WHERE status = 'open' DO NOTHING
98        "#,
99        user_id,
100        course_id,
101        LIFETIME_AFTER_LAST_WRITE_DAYS as i32,
102    )
103    .execute(&mut *conn)
104    .await?;
105    get_open(conn, user_id, course_id)
106        .await?
107        .ok_or_else(missing_model_error(
108            ModelErrorType::Generic,
109            "Open agent changeset vanished",
110        ))
111}
112
113/// The user's open changeset for the course, if any. See [`get_or_create_open`].
114pub async fn get_open(
115    conn: &mut PgConnection,
116    user_id: Uuid,
117    course_id: Uuid,
118) -> ModelResult<Option<AgentChangeset>> {
119    let res = sqlx::query_as!(
120        AgentChangeset,
121        r#"
122SELECT *
123FROM agent_changesets
124WHERE user_id = $1
125  AND course_id = $2
126  AND status = 'open'
127  AND expires_at > now()
128        "#,
129        user_id,
130        course_id,
131    )
132    .fetch_optional(conn)
133    .await?;
134    Ok(res)
135}
136
137/// The user's open changeset that changes the page, if any.
138pub async fn get_open_changing_page(
139    conn: &mut PgConnection,
140    user_id: Uuid,
141    page_id: Uuid,
142) -> ModelResult<Option<AgentChangeset>> {
143    let res = sqlx::query_as!(
144        AgentChangeset,
145        r#"
146SELECT cs.*
147FROM agent_changesets cs
148  JOIN agent_changeset_entities e ON e.changeset_id = cs.id
149WHERE cs.user_id = $1
150  AND cs.status = 'open'
151  AND cs.expires_at > now()
152  AND e.entity_type = 'page'
153  AND e.entity_id = $2
154        "#,
155        user_id,
156        page_id,
157    )
158    .fetch_optional(conn)
159    .await?;
160    Ok(res)
161}
162
163/// The changeset, whatever its status.
164pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<AgentChangeset> {
165    let res = sqlx::query_as!(
166        AgentChangeset,
167        r#"
168SELECT *
169FROM agent_changesets
170WHERE id = $1
171        "#,
172        id,
173    )
174    .fetch_one(conn)
175    .await?;
176    Ok(res.with_current_status())
177}
178
179/// The user's changeset. Errors with `NotFound` if it is someone else's, so that callers don't
180/// reveal other users' changesets.
181pub async fn get_by_id_for_user(
182    conn: &mut PgConnection,
183    id: Uuid,
184    user_id: Uuid,
185) -> ModelResult<AgentChangeset> {
186    let changeset = get_by_id(conn, id).await?;
187    if changeset.user_id != user_id {
188        return Err(model_err!(NotFound, "Changeset not found".to_string()));
189    }
190    Ok(changeset)
191}
192
193/// Like [`get_by_id`], locking the row until the transaction ends so that publishing, discarding
194/// and staging of one changeset never interleave.
195pub async fn get_by_id_for_update(
196    conn: &mut PgConnection,
197    id: Uuid,
198) -> ModelResult<AgentChangeset> {
199    let res = sqlx::query_as!(
200        AgentChangeset,
201        r#"
202SELECT *
203FROM agent_changesets
204WHERE id = $1
205FOR UPDATE
206        "#,
207        id,
208    )
209    .fetch_one(conn)
210    .await?;
211    Ok(res.with_current_status())
212}
213
214/// The user's changesets in the course, newest first.
215pub async fn get_by_user_and_course(
216    conn: &mut PgConnection,
217    user_id: Uuid,
218    course_id: Uuid,
219) -> ModelResult<Vec<AgentChangeset>> {
220    let res = sqlx::query_as!(
221        AgentChangeset,
222        r#"
223SELECT *
224FROM agent_changesets
225WHERE user_id = $1
226  AND course_id = $2
227ORDER BY created_at DESC,
228  id
229        "#,
230        user_id,
231        course_id,
232    )
233    .fetch_all(conn)
234    .await?;
235    Ok(res
236        .into_iter()
237        .map(AgentChangeset::with_current_status)
238        .collect())
239}
240
241/// The courses the user has changesets in.
242pub async fn get_course_ids_by_user(
243    conn: &mut PgConnection,
244    user_id: Uuid,
245) -> ModelResult<Vec<Uuid>> {
246    let res = sqlx::query_scalar!(
247        r#"
248SELECT DISTINCT course_id
249FROM agent_changesets
250WHERE user_id = $1
251        "#,
252        user_id,
253    )
254    .fetch_all(conn)
255    .await?;
256    Ok(res)
257}
258
259/// The user's open changesets in the courses and their `closed_limit` most recent closed ones,
260/// newest first.
261pub async fn get_open_and_recent_by_user(
262    conn: &mut PgConnection,
263    user_id: Uuid,
264    course_ids: &[Uuid],
265    closed_limit: i64,
266) -> ModelResult<Vec<AgentChangeset>> {
267    let res = sqlx::query_as!(
268        AgentChangeset,
269        r#"
270SELECT id AS "id!",
271  created_at AS "created_at!",
272  updated_at AS "updated_at!",
273  user_id AS "user_id!",
274  course_id AS "course_id!",
275  status AS "status!",
276  expires_at AS "expires_at!",
277  published_at,
278  revision AS "revision!"
279FROM (
280    (
281      SELECT *
282      FROM agent_changesets
283      WHERE user_id = $1
284        AND course_id = ANY($2)
285        AND status = 'open'
286        AND expires_at > now()
287    )
288    UNION ALL
289    (
290      SELECT *
291      FROM agent_changesets
292      WHERE user_id = $1
293        AND course_id = ANY($2)
294        AND (
295          status <> 'open'
296          OR expires_at <= now()
297        )
298      ORDER BY created_at DESC,
299        id
300      LIMIT $3
301    )
302  ) listed
303ORDER BY created_at DESC,
304  id
305        "#,
306        user_id,
307        course_ids,
308        closed_limit,
309    )
310    .fetch_all(conn)
311    .await?;
312    Ok(res
313        .into_iter()
314        .map(AgentChangeset::with_current_status)
315        .collect())
316}
317
318/// What the course management view shows of the user's changesets in a course without listing
319/// them.
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
321pub struct AgentChangesetCounts {
322    /// Whether the user has any changeset in the course, open or not.
323    pub has_changesets: bool,
324    /// How many entities the user's open changeset stages; 0 without one.
325    pub open_entity_count: i64,
326}
327
328/// The user's [`AgentChangesetCounts`] in the course.
329pub async fn get_counts(
330    conn: &mut PgConnection,
331    user_id: Uuid,
332    course_id: Uuid,
333) -> ModelResult<AgentChangesetCounts> {
334    let res = sqlx::query_as!(
335        AgentChangesetCounts,
336        r#"
337SELECT EXISTS (
338    SELECT 1
339    FROM agent_changesets
340    WHERE user_id = $1
341      AND course_id = $2
342  ) AS "has_changesets!",
343  (
344    SELECT count(*)
345    FROM agent_changeset_entities e
346      JOIN agent_changesets cs ON cs.id = e.changeset_id
347    WHERE cs.user_id = $1
348      AND cs.course_id = $2
349      AND cs.status = 'open'
350      AND cs.expires_at > now()
351  ) AS "open_entity_count!"
352        "#,
353        user_id,
354        course_id,
355    )
356    .fetch_one(conn)
357    .await?;
358    Ok(res)
359}
360
361/// Records a write to an open changeset the caller has locked with [`get_by_id_for_update`]:
362/// increments its revision and pushes its expiry to [`LIFETIME_AFTER_LAST_WRITE_DAYS`] from now.
363pub async fn record_write(conn: &mut PgConnection, id: Uuid) -> ModelResult<AgentChangeset> {
364    let res = sqlx::query_as!(
365        AgentChangeset,
366        r#"
367UPDATE agent_changesets
368SET revision = revision + 1,
369  expires_at = now() + make_interval(days => $2)
370WHERE id = $1
371  AND status = 'open'
372  AND expires_at > now()
373RETURNING *
374        "#,
375        id,
376        LIFETIME_AFTER_LAST_WRITE_DAYS as i32,
377    )
378    .fetch_one(conn)
379    .await?;
380    Ok(res)
381}
382
383/// Marks an open changeset published. Errors if it is no longer open.
384pub async fn mark_published(conn: &mut PgConnection, id: Uuid) -> ModelResult<AgentChangeset> {
385    let res = sqlx::query_as!(
386        AgentChangeset,
387        r#"
388UPDATE agent_changesets
389SET status = 'published',
390  published_at = now()
391WHERE id = $1
392  AND status = 'open'
393  AND expires_at > now()
394RETURNING *
395        "#,
396        id,
397    )
398    .fetch_one(conn)
399    .await?;
400    Ok(res)
401}
402
403/// Drops an open changeset's staged changes. Errors with `RecordNotFound` if it is not open.
404pub async fn discard(conn: &mut PgConnection, id: Uuid) -> ModelResult<AgentChangeset> {
405    let res = sqlx::query_as!(
406        AgentChangeset,
407        r#"
408UPDATE agent_changesets
409SET status = 'discarded'
410WHERE id = $1
411  AND status = 'open'
412  AND expires_at > now()
413RETURNING *
414        "#,
415        id,
416    )
417    .fetch_one(conn)
418    .await?;
419    Ok(res)
420}
421
422/// Increments the revision of a changeset the caller has locked with [`get_by_id_for_update`]
423/// and returns the new revision.
424pub async fn increment_revision(conn: &mut PgConnection, id: Uuid) -> ModelResult<i32> {
425    let revision = sqlx::query_scalar!(
426        r#"
427UPDATE agent_changesets
428SET revision = revision + 1
429WHERE id = $1
430RETURNING revision
431        "#,
432        id,
433    )
434    .fetch_one(conn)
435    .await?;
436    Ok(revision)
437}