Skip to main content

headless_lms_models/
open_university_product_access_tokens.rs

1use secrecy::ExposeSecret;
2
3use crate::prelude::*;
4
5/// The same base the scraped registration links used, so a student sees the page they saw before
6/// this pipeline existed.
7const OPEN_UNIVERSITY_ENROLMENT_BASE_URL: &str =
8    "https://www.avoin.helsinki.fi/palvelut/esittely.aspx?s=";
9
10/// The enrolment page a student with no usable enrolment is sent to, or `None` for a product whose
11/// refresh has never succeeded. The token is exposed here on purpose and nowhere else: it must not
12/// reach a log line or a stored response body.
13pub fn enrolment_url(token: &OpenUniversityProductAccessToken) -> Option<String> {
14    let access_token = token.access_token.as_ref()?;
15    Some(format!(
16        "{OPEN_UNIVERSITY_ENROLMENT_BASE_URL}{}",
17        access_token.expose_secret()
18    ))
19}
20
21/// The enrolment page for a module's configured product, following the whole fallback chain:
22/// no product configured, or no refresh has ever succeeded for it, means `None` and the caller
23/// degrades to generic "enrol in Sisu" copy.
24///
25/// Every surface that offers a student a way back into an enrolment goes through this, so the mail,
26/// the status page and the profile cannot disagree about whether a link exists.
27pub async fn enrolment_url_for_product(
28    conn: &mut PgConnection,
29    open_university_product_id: Option<&str>,
30) -> ModelResult<Option<String>> {
31    let Some(product_id) = open_university_product_id else {
32        return Ok(None);
33    };
34    Ok(get_by_product_id(conn, product_id)
35        .await?
36        .as_ref()
37        .and_then(enrolment_url))
38}
39
40#[derive(Debug, Clone)]
41pub struct OpenUniversityProductAccessToken {
42    pub id: Uuid,
43    pub created_at: DateTime<Utc>,
44    pub updated_at: DateTime<Utc>,
45    pub deleted_at: Option<DateTime<Utc>>,
46    pub open_university_product_id: String,
47    /// `None` on a row that only records failures: no refresh has ever succeeded for the product.
48    pub access_token: Option<DbSecret>,
49    pub state: Option<String>,
50    pub document_state: Option<String>,
51    pub suotar_token_id: Option<String>,
52    pub last_refreshed_at: Option<DateTime<Utc>>,
53    pub last_refresh_failed_at: Option<DateTime<Utc>>,
54    pub last_refresh_error: Option<String>,
55    pub consecutive_failures: i32,
56}
57
58#[derive(Debug, Clone)]
59pub struct NewOpenUniversityProductAccessToken {
60    pub open_university_product_id: String,
61    pub access_token: DbSecret,
62    pub state: String,
63    pub document_state: String,
64    pub suotar_token_id: Option<String>,
65}
66
67/// Stores a freshly fetched token, replacing whatever we held for the product.
68pub async fn upsert(
69    conn: &mut PgConnection,
70    new: &NewOpenUniversityProductAccessToken,
71) -> ModelResult<Uuid> {
72    let res = sqlx::query!(
73        r#"
74INSERT INTO open_university_product_access_tokens (
75    open_university_product_id,
76    access_token,
77    state,
78    document_state,
79    suotar_token_id,
80    last_refreshed_at
81  )
82VALUES ($1, $2, $3, $4, $5, now()) ON CONFLICT (open_university_product_id, deleted_at) DO
83UPDATE
84SET access_token = $2,
85  state = $3,
86  document_state = $4,
87  suotar_token_id = $5,
88  last_refreshed_at = now(),
89  last_refresh_failed_at = NULL,
90  last_refresh_error = NULL,
91  consecutive_failures = 0
92RETURNING id
93        "#,
94        new.open_university_product_id,
95        new.access_token.expose_secret(),
96        new.state,
97        new.document_state,
98        new.suotar_token_id,
99    )
100    .fetch_one(conn)
101    .await?;
102    Ok(res.id)
103}
104
105pub async fn get_by_product_id(
106    conn: &mut PgConnection,
107    open_university_product_id: &str,
108) -> ModelResult<Option<OpenUniversityProductAccessToken>> {
109    let res = sqlx::query_as!(
110        OpenUniversityProductAccessToken,
111        r#"
112SELECT *
113FROM open_university_product_access_tokens
114WHERE open_university_product_id = $1
115  AND deleted_at IS NULL
116        "#,
117        open_university_product_id
118    )
119    .fetch_optional(conn)
120    .await?;
121    Ok(res)
122}
123
124pub async fn get_all(
125    conn: &mut PgConnection,
126) -> ModelResult<Vec<OpenUniversityProductAccessToken>> {
127    let res = sqlx::query_as!(
128        OpenUniversityProductAccessToken,
129        r#"
130SELECT *
131FROM open_university_product_access_tokens
132WHERE deleted_at IS NULL
133ORDER BY open_university_product_id
134        "#,
135    )
136    .fetch_all(conn)
137    .await?;
138    Ok(res)
139}
140
141/// Records a failed refresh without touching the token: a stale token still beats none.
142///
143/// Creates a token-less row for a product that has never had a successful refresh. An `UPDATE`
144/// would match nothing there, leaving a mistyped product id with no diagnosis and no
145/// `last_refresh_failed_at` to order it behind the products still worth trying.
146pub async fn record_refresh_failure(
147    conn: &mut PgConnection,
148    open_university_product_id: &str,
149    error: &str,
150) -> ModelResult<()> {
151    sqlx::query!(
152        r#"
153INSERT INTO open_university_product_access_tokens (
154    open_university_product_id,
155    last_refresh_failed_at,
156    last_refresh_error,
157    consecutive_failures
158  )
159VALUES ($1, now(), $2, 1) ON CONFLICT (open_university_product_id, deleted_at) DO
160UPDATE
161SET last_refresh_failed_at = now(),
162  last_refresh_error = $2,
163  consecutive_failures = open_university_product_access_tokens.consecutive_failures + 1
164        "#,
165        open_university_product_id,
166        error,
167    )
168    .execute(conn)
169    .await?;
170    Ok(())
171}
172
173pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
174    sqlx::query!(
175        r#"
176UPDATE open_university_product_access_tokens
177SET deleted_at = now()
178WHERE id = $1
179  AND deleted_at IS NULL
180        "#,
181        id
182    )
183    .execute(conn)
184    .await?;
185    Ok(())
186}