1use chrono::Duration;
4use utoipa::ToSchema;
5
6use crate::{error::missing_model_error, prelude::*};
7
8const LIFETIME_AFTER_LAST_WRITE_DAYS: i64 = 14;
10
11const 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 pub user_id: Uuid,
31 pub course_id: Uuid,
32 pub status: AgentChangesetStatus,
35 pub expires_at: DateTime<Utc>,
36 pub published_at: Option<DateTime<Utc>>,
37 pub revision: i32,
39}
40
41impl AgentChangesetStatus {
42 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 fn with_current_status(mut self) -> Self {
56 self.status = self.status.at(self.expires_at, Utc::now());
57 self
58 }
59
60 pub fn is_open(&self) -> bool {
62 self.status == AgentChangesetStatus::Open
63 }
64
65 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
71pub async fn get_or_create_open(
73 conn: &mut PgConnection,
74 user_id: Uuid,
75 course_id: Uuid,
76) -> ModelResult<AgentChangeset> {
77 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
113pub 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
137pub 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
163pub 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
179pub 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
193pub 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
214pub 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
241pub 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
259pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
321pub struct AgentChangesetCounts {
322 pub has_changesets: bool,
324 pub open_entity_count: i64,
326}
327
328pub 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
361pub 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
383pub 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
403pub 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
422pub 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}