Skip to main content

headless_lms_models/
entity_history.rs

1//! A snapshot of a chapter, course module or course's settings after every save. Page history
2//! lives in `page_history`.
3
4use crate::{
5    library::entity_snapshots::{self, ContentEntityType, EntityKey, EntitySnapshot},
6    prelude::*,
7};
8
9/// Why a history row was written.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Type)]
11#[sqlx(type_name = "entity_history_change_reason", rename_all = "snake_case")]
12pub enum EntityHistoryChangeReason {
13    Saved,
14    Deleted,
15    Restored,
16}
17
18/// Who made a change that history records.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct HistoryAuthor {
21    pub user_id: Uuid,
22    /// For an agent change, the latest call that staged it before it was published; `None` for
23    /// changes made in the web UI.
24    pub agent_call_id: Option<Uuid>,
25}
26
27impl HistoryAuthor {
28    /// An author who made the change in the web UI.
29    pub fn user(user_id: Uuid) -> Self {
30        Self {
31            user_id,
32            agent_call_id: None,
33        }
34    }
35
36    /// An author whose change was staged by agent call `call_id`.
37    pub fn agent(user_id: Uuid, call_id: Uuid) -> Self {
38        Self {
39            user_id,
40            agent_call_id: Some(call_id),
41        }
42    }
43}
44
45/// One history row; see [`insert`].
46#[derive(Debug, Clone, PartialEq)]
47pub struct EntityHistoryEntry {
48    pub id: Uuid,
49    pub created_at: DateTime<Utc>,
50    pub updated_at: DateTime<Utc>,
51    pub entity_type: ContentEntityType,
52    /// The chapter or module id; the course id for course settings.
53    pub entity_id: Uuid,
54    pub course_id: Uuid,
55    /// The entity's fields after the change, in its snapshot shape.
56    pub snapshot: serde_json::Value,
57    pub change_reason: EntityHistoryChangeReason,
58    pub restored_from_id: Option<Uuid>,
59    pub author_user_id: Uuid,
60    pub agent_call_id: Option<Uuid>,
61}
62
63/// Records `snapshot` as a new history row. Only chapters, modules and course settings have
64/// history; the database refuses other entity types.
65pub async fn insert(
66    conn: &mut PgConnection,
67    snapshot: &EntitySnapshot,
68    change_reason: EntityHistoryChangeReason,
69    author: HistoryAuthor,
70    restored_from_id: Option<Uuid>,
71) -> ModelResult<Uuid> {
72    let id = sqlx::query_scalar!(
73        r#"
74INSERT INTO entity_history (
75    entity_type,
76    entity_id,
77    course_id,
78    snapshot,
79    change_reason,
80    restored_from_id,
81    author_user_id,
82    agent_call_id
83  )
84VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
85RETURNING id
86        "#,
87        snapshot.entity_type() as ContentEntityType,
88        snapshot.entity_id(),
89        snapshot.course_id(),
90        serde_json::to_value(snapshot)?,
91        change_reason as EntityHistoryChangeReason,
92        restored_from_id,
93        author.user_id,
94        author.agent_call_id,
95    )
96    .fetch_one(conn)
97    .await?;
98    Ok(id)
99}
100
101/// Records each snapshot as saved, except those equal to their entity's latest history row.
102pub async fn record_saved(
103    conn: &mut PgConnection,
104    snapshots: &[EntitySnapshot],
105    author: HistoryAuthor,
106) -> ModelResult<()> {
107    if snapshots.is_empty() {
108        return Ok(());
109    }
110    let mut entity_types = Vec::with_capacity(snapshots.len());
111    let mut entity_ids = Vec::with_capacity(snapshots.len());
112    let mut course_ids = Vec::with_capacity(snapshots.len());
113    let mut values = Vec::with_capacity(snapshots.len());
114    for snapshot in snapshots {
115        entity_types.push(snapshot.entity_type());
116        entity_ids.push(snapshot.entity_id());
117        course_ids.push(snapshot.course_id());
118        values.push(serde_json::to_value(snapshot)?);
119    }
120    sqlx::query!(
121        r#"
122INSERT INTO entity_history (
123    entity_type,
124    entity_id,
125    course_id,
126    snapshot,
127    change_reason,
128    author_user_id,
129    agent_call_id
130  )
131SELECT k.entity_type,
132  k.entity_id,
133  k.course_id,
134  k.snapshot,
135  'saved',
136  $5,
137  $6
138FROM UNNEST(
139    $1::content_entity_type [],
140    $2::uuid [],
141    $3::uuid [],
142    $4::jsonb []
143  ) AS k(entity_type, entity_id, course_id, snapshot)
144WHERE (
145    SELECT h.snapshot
146    FROM entity_history h
147    WHERE h.entity_type = k.entity_type
148      AND h.entity_id = k.entity_id
149    ORDER BY h.created_at DESC,
150      h.id DESC
151    LIMIT 1
152  ) IS DISTINCT FROM k.snapshot
153        "#,
154        entity_types as Vec<ContentEntityType>,
155        &entity_ids,
156        &course_ids,
157        &values,
158        author.user_id,
159        author.agent_call_id,
160    )
161    .execute(conn)
162    .await?;
163    Ok(())
164}
165
166/// Runs `save` in a transaction and then records the entity's state as [`record_current`] does.
167pub async fn save_recording<T>(
168    conn: &mut PgConnection,
169    entity_type: ContentEntityType,
170    entity_id: Uuid,
171    author: HistoryAuthor,
172    save: impl AsyncFnOnce(&mut PgConnection) -> ModelResult<T>,
173) -> ModelResult<T> {
174    let mut tx = conn.begin().await?;
175    let saved = save(&mut *tx).await?;
176    record_current(&mut tx, entity_type, entity_id, author).await?;
177    tx.commit().await?;
178    Ok(saved)
179}
180
181/// Records the current state of the entities as saved, as [`record_saved`] does. Deleted and
182/// missing entities are skipped.
183pub async fn record_current_many(
184    conn: &mut PgConnection,
185    keys: &[EntityKey],
186    author: HistoryAuthor,
187) -> ModelResult<()> {
188    let snapshots: Vec<EntitySnapshot> = entity_snapshots::load_live_many(&mut *conn, keys)
189        .await?
190        .into_values()
191        .flatten()
192        .collect();
193    record_saved(conn, &snapshots, author).await
194}
195
196/// [`record_current_many`] of one entity.
197pub async fn record_current(
198    conn: &mut PgConnection,
199    entity_type: ContentEntityType,
200    entity_id: Uuid,
201    author: HistoryAuthor,
202) -> ModelResult<()> {
203    record_current_many(conn, &[EntityKey::new(entity_type, entity_id)], author).await
204}
205
206/// Records the entity's current state as deleted. Call it before deleting the entity; does
207/// nothing if it is already gone.
208pub async fn record_deleted(
209    conn: &mut PgConnection,
210    entity_type: ContentEntityType,
211    entity_id: Uuid,
212    author: HistoryAuthor,
213) -> ModelResult<()> {
214    if let Some(snapshot) =
215        entity_snapshots::load_live(&mut *conn, EntityKey::new(entity_type, entity_id)).await?
216    {
217        insert(
218            conn,
219            &snapshot,
220            EntityHistoryChangeReason::Deleted,
221            author,
222            None,
223        )
224        .await?;
225    }
226    Ok(())
227}
228
229/// The history row.
230pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<EntityHistoryEntry> {
231    let res = sqlx::query_as!(
232        EntityHistoryEntry,
233        r#"
234SELECT *
235FROM entity_history
236WHERE id = $1
237        "#,
238        id,
239    )
240    .fetch_one(conn)
241    .await?;
242    Ok(res)
243}
244
245/// The newest history row of an entity: who last changed it and when.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct EntityVersion {
248    pub created_at: DateTime<Utc>,
249    pub author_user_id: Uuid,
250    /// The agent client the change was made through; `None` for the web UI.
251    pub agent_client_name: Option<String>,
252}
253
254/// The newest `page_history` or `entity_history` row of the page, chapter, module or course
255/// settings with the id, only in the course when `course_id` is given. `None` for glossary terms
256/// and references, which have no history, and for entities never saved since history was
257/// introduced.
258pub async fn latest_version(
259    conn: &mut PgConnection,
260    entity_id: Uuid,
261    course_id: Option<Uuid>,
262) -> ModelResult<Option<EntityVersion>> {
263    let version = sqlx::query_as!(
264        EntityVersion,
265        r#"
266SELECT h.created_at AS "created_at!",
267  h.author_user_id AS "author_user_id!",
268  c.client_name AS "agent_client_name?"
269FROM (
270    SELECT ph.id,
271      ph.created_at,
272      ph.author_user_id,
273      ph.agent_call_id
274    FROM page_history ph
275      JOIN pages p ON p.id = ph.page_id
276    WHERE ph.page_id = $1
277      AND (
278        $2::uuid IS NULL
279        OR p.course_id = $2
280      )
281      AND ph.deleted_at IS NULL
282    UNION ALL
283    SELECT id,
284      created_at,
285      author_user_id,
286      agent_call_id
287    FROM entity_history
288    WHERE entity_id = $1
289      AND (
290        $2::uuid IS NULL
291        OR course_id = $2
292      )
293  ) h
294  LEFT JOIN agent_changeset_calls c ON c.id = h.agent_call_id
295ORDER BY h.created_at DESC,
296  h.id DESC
297LIMIT 1
298        "#,
299        entity_id,
300        course_id,
301    )
302    .fetch_optional(conn)
303    .await?;
304    Ok(version)
305}