bhtune_db/models/
demo_sessions.rs1use chrono::{DateTime, Utc};
2use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
3
4use crate::error::{DbError, DbResult};
5
6pub const DEMO_RESTART_INTERRUPTED_REASON: &str = "demo run was interrupted by a server restart";
11
12#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct DemoSessionRow {
16 pub id: i64,
17 pub token_hash: String,
18 pub created_at: DateTime<Utc>,
19 pub last_seen_at: DateTime<Utc>,
20 pub expires_at: DateTime<Utc>,
21 pub revoked_at: Option<DateTime<Utc>>,
22}
23
24impl DemoSessionRow {
25 pub async fn create(
28 pool: &SqlitePool,
29 token_hash: &str,
30 now: DateTime<Utc>,
31 expires_at: DateTime<Utc>,
32 ) -> DbResult<Self> {
33 Self::get_or_create(pool, token_hash, now, expires_at).await
34 }
35
36 pub async fn get_or_create(
43 pool: &SqlitePool,
44 token_hash: &str,
45 now: DateTime<Utc>,
46 expires_at: DateTime<Utc>,
47 ) -> DbResult<Self> {
48 let inserted = sqlx::query(
49 "INSERT INTO demo_sessions (token_hash, created_at, last_seen_at, expires_at) \
50 VALUES (?, ?, ?, ?) \
51 ON CONFLICT(token_hash) DO NOTHING \
52 RETURNING *",
53 )
54 .bind(token_hash)
55 .bind(now)
56 .bind(now)
57 .bind(expires_at)
58 .fetch_optional(pool)
59 .await
60 .map_err(DbError::Query)?;
61
62 match inserted {
63 Some(row) => row_to_demo_session(row),
64 None => Self::get_by_token_hash(pool, token_hash, now)
65 .await?
66 .ok_or_else(|| DbError::Query(sqlx::Error::RowNotFound)),
67 }
68 }
69
70 pub async fn get_by_token_hash(
74 pool: &SqlitePool,
75 token_hash: &str,
76 now: DateTime<Utc>,
77 ) -> DbResult<Option<Self>> {
78 let row = sqlx::query(
79 "UPDATE demo_sessions \
80 SET last_seen_at = MAX(last_seen_at, ?) \
81 WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? \
82 RETURNING *",
83 )
84 .bind(now)
85 .bind(token_hash)
86 .bind(now)
87 .fetch_optional(pool)
88 .await
89 .map_err(DbError::Query)?;
90 row.map(row_to_demo_session).transpose()
91 }
92
93 pub async fn touch_by_token_hash(
96 pool: &SqlitePool,
97 token_hash: &str,
98 now: DateTime<Utc>,
99 ) -> DbResult<Option<Self>> {
100 Self::get_by_token_hash(pool, token_hash, now).await
101 }
102
103 pub async fn revoke(pool: &SqlitePool, id: i64, now: DateTime<Utc>) -> DbResult<bool> {
104 let result = sqlx::query(
105 "UPDATE demo_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
106 )
107 .bind(now)
108 .bind(id)
109 .execute(pool)
110 .await
111 .map_err(DbError::Query)?;
112 Ok(result.rows_affected() != 0)
113 }
114
115 pub async fn cleanup_expired(pool: &SqlitePool, now: DateTime<Utc>) -> DbResult<u64> {
121 let result = sqlx::query(
122 "DELETE FROM demo_sessions \
123 WHERE (expires_at <= ? OR revoked_at IS NOT NULL) \
124 AND NOT EXISTS ( \
125 SELECT 1 FROM tune_runs \
126 WHERE tune_runs.demo_session_id = demo_sessions.id \
127 AND tune_runs.outcome = 'running' \
128 )",
129 )
130 .bind(now)
131 .execute(pool)
132 .await
133 .map_err(DbError::Query)?;
134 Ok(result.rows_affected())
135 }
136
137 pub async fn recover_running_demo_runs(pool: &SqlitePool, now: DateTime<Utc>) -> DbResult<u64> {
140 let result = sqlx::query(
141 "UPDATE tune_runs SET outcome = 'failed', completed_at = ?, \
142 failure_reason = ? \
143 WHERE demo_session_id IS NOT NULL AND outcome = 'running'",
144 )
145 .bind(now)
146 .bind(DEMO_RESTART_INTERRUPTED_REASON)
147 .execute(pool)
148 .await
149 .map_err(DbError::Query)?;
150 Ok(result.rows_affected())
151 }
152}
153
154fn row_to_demo_session(row: SqliteRow) -> DbResult<DemoSessionRow> {
155 Ok(DemoSessionRow {
156 id: row.try_get("id").map_err(DbError::Query)?,
157 token_hash: row.try_get("token_hash").map_err(DbError::Query)?,
158 created_at: row.try_get("created_at").map_err(DbError::Query)?,
159 last_seen_at: row.try_get("last_seen_at").map_err(DbError::Query)?,
160 expires_at: row.try_get("expires_at").map_err(DbError::Query)?,
161 revoked_at: row.try_get("revoked_at").map_err(DbError::Query)?,
162 })
163}