1use crate::{
5 library::entity_snapshots::{self, ContentEntityType, EntityKey, EntitySnapshot},
6 prelude::*,
7};
8
9#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct HistoryAuthor {
21 pub user_id: Uuid,
22 pub agent_call_id: Option<Uuid>,
25}
26
27impl HistoryAuthor {
28 pub fn user(user_id: Uuid) -> Self {
30 Self {
31 user_id,
32 agent_call_id: None,
33 }
34 }
35
36 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#[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 pub entity_id: Uuid,
54 pub course_id: Uuid,
55 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
63pub 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
101pub 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
166pub 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
181pub 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
196pub 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
206pub 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
229pub 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#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct EntityVersion {
248 pub created_at: DateTime<Utc>,
249 pub author_user_id: Uuid,
250 pub agent_client_name: Option<String>,
252}
253
254pub 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}