Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
api_log.rs

1//! The Suotar API log tab: one row per HTTP call to the study registry, and what each covered.
2//!
3//! The stored bodies are scrubbed at write time — names, student numbers and email addresses read
4//! `[redacted]` while their keys survive — and are returned exactly as stored. What makes the log
5//! navigable is not the body but `credit_registration_ids`: every call resolves to the ledger rows
6//! it carried, and those hold the real values.
7
8use headless_lms_models::credit_registration_events::CreditRegistrationEventKind;
9use headless_lms_models::credit_registrations::{
10    self, AdminCreditRegistrationFilters, AdminCreditRegistrationSort, CreditRegistrationErrorCode,
11    CreditRegistrationState,
12};
13use headless_lms_models::suotar_api_calls::{
14    self, SuotarApiCall, SuotarApiCallFilters, SuotarApiCallPageRow, SuotarEndpoint,
15};
16use utoipa::ToSchema;
17
18use crate::prelude::*;
19use headless_lms_utils::secret_string::expose_option;
20
21use super::authorize_credit_registration_admin;
22
23/// How many ledger rows one call may resolve: the largest batch any endpoint carries.
24const MAX_REFERENCED_ROWS: i64 = 1000;
25
26#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
27pub struct SuotarApiCallRow {
28    pub id: Uuid,
29    pub endpoint: SuotarEndpoint,
30    pub started_at: DateTime<Utc>,
31    pub duration_ms: Option<i32>,
32    /// `None` with `succeeded = false` means the request never got an answer: connect, TLS or
33    /// timeout.
34    pub http_status: Option<i32>,
35    pub succeeded: bool,
36    pub request_item_count: i32,
37    pub ok_item_count: i32,
38    pub error_item_count: i32,
39    pub pending_item_count: i32,
40    /// The registry's own request-level code, an identifier rather than prose.
41    pub request_level_error_code: Option<String>,
42    pub worker_name: String,
43    pub credit_registration_ids: Vec<Uuid>,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
47pub struct SuotarApiCallsPage {
48    #[serde(flatten)]
49    pub page: Page<SuotarApiCallRow>,
50    /// The `worker_name` values in the log, for the filter.
51    pub worker_names: Vec<String>,
52}
53
54/// One ledger row a call carried, resolved from `credit_registration_ids`. This is what stands in
55/// for the redacted body: the identifiers are here, beside the item they belong to.
56#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
57pub struct SuotarApiCallLedgerReference {
58    pub credit_registration_id: Uuid,
59    /// The id the registry saw for this row, so a line of the stored body maps to a student. `None`
60    /// when no event recorded it.
61    pub request_item_id: Option<String>,
62    pub user_id: Uuid,
63    pub first_name: Option<String>,
64    pub last_name: Option<String>,
65    pub email: Option<String>,
66    pub student_number: Option<String>,
67    pub course_id: Uuid,
68    pub course_name: String,
69    pub state: CreditRegistrationState,
70    pub error_code: Option<CreditRegistrationErrorCode>,
71}
72
73/// A timeline entry written against this call, one per item the answer moved.
74#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
75pub struct SuotarApiCallEvent {
76    pub id: Uuid,
77    pub credit_registration_id: Uuid,
78    pub created_at: DateTime<Utc>,
79    pub kind: CreditRegistrationEventKind,
80    pub from_state: Option<CreditRegistrationState>,
81    pub to_state: Option<CreditRegistrationState>,
82    pub error_code: Option<CreditRegistrationErrorCode>,
83    /// Our own wording for what the call did to the row.
84    pub message: Option<String>,
85    /// The `{request, response}` pair for this one item, scrubbed at write time.
86    pub details: Option<serde_json::Value>,
87}
88
89#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
90pub struct SuotarApiCallDetails {
91    pub call: SuotarApiCallRow,
92    /// Scrubbed and truncated when it was written; never un-scrubbed for display.
93    pub request_body_sample: Option<serde_json::Value>,
94    pub response_body_sample: Option<serde_json::Value>,
95    /// Our own wording; the registry's error prose is stored for nobody.
96    pub error_message: Option<String>,
97    pub ledger_references: Vec<SuotarApiCallLedgerReference>,
98    pub events: Vec<SuotarApiCallEvent>,
99}
100
101#[derive(Debug, Deserialize)]
102pub struct ListSuotarApiCallsQuery {
103    page: Option<u32>,
104    limit: Option<u32>,
105    endpoint: Option<SuotarEndpoint>,
106    succeeded: Option<bool>,
107    worker_name: Option<String>,
108    started_after: Option<DateTime<Utc>>,
109    started_before: Option<DateTime<Utc>>,
110    credit_registration_id: Option<Uuid>,
111}
112
113/**
114GET `/api/v0/main-frontend/credit-registration-admin/suotar-api-calls` - A page of the study
115registry call log, newest first.
116
117Filtering by `credit_registration_id` is how "find the call that carried this student" is answered:
118the bodies are scrubbed, so there is no student number in them to search.
119*/
120#[instrument(skip(pool))]
121#[utoipa::path(
122    get,
123    path = "/suotar-api-calls",
124    operation_id = "listSuotarApiCalls",
125    tag = "credit-registration-admin",
126    params(
127        ("page" = Option<u32>, Query, description = "Page number, from 1"),
128        ("limit" = Option<u32>, Query, description = "Rows per page"),
129        ("endpoint" = Option<SuotarEndpoint>, Query, description = "One study registry endpoint"),
130        ("succeeded" = Option<bool>, Query, description = "Only calls that did, or did not, succeed"),
131        ("worker_name" = Option<String>, Query, description = "The phase or manual action that made the call"),
132        ("started_after" = Option<DateTime<Utc>>, Query, description = "Started at or after"),
133        ("started_before" = Option<DateTime<Utc>>, Query, description = "Started at or before"),
134        ("credit_registration_id" = Option<Uuid>, Query, description = "Only calls that carried this ledger row")
135    ),
136    responses(
137        (status = 200, description = "A page of the call log", body = SuotarApiCallsPage)
138    )
139)]
140pub async fn list_suotar_api_calls(
141    user: AuthUser,
142    pool: web::Data<PgPool>,
143    query: web::Query<ListSuotarApiCallsQuery>,
144) -> ControllerResult<web::Json<SuotarApiCallsPage>> {
145    let mut conn = pool.acquire().await?;
146    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
147
148    let pagination = parse_pagination(query.page, query.limit, 50)?;
149    let filters = SuotarApiCallFilters {
150        endpoint: query.endpoint,
151        succeeded: query.succeeded,
152        worker_name: non_empty(query.worker_name.as_deref()).map(str::to_string),
153        started_after: query.started_after,
154        started_before: query.started_before,
155        credit_registration_id: query.credit_registration_id,
156    };
157    let rows =
158        suotar_api_calls::get_page(&mut conn, &filters, pagination.limit(), pagination.offset())
159            .await?;
160    let total_count = rows.first().map_or(0, |row| row.total_count);
161    let worker_names = suotar_api_calls::get_worker_names(&mut conn).await?;
162
163    token.authorized_ok(web::Json(SuotarApiCallsPage {
164        page: Page::new(
165            pagination,
166            rows.into_iter().map(to_call_row).collect(),
167            total_count,
168        ),
169        worker_names,
170    }))
171}
172
173/**
174GET `/api/v0/main-frontend/credit-registration-admin/suotar-api-calls/{suotar_api_call_id}` - One
175call with its stored bodies, the ledger rows it covered and the timeline entries it produced.
176*/
177#[instrument(skip(pool))]
178#[utoipa::path(
179    get,
180    path = "/suotar-api-calls/{suotar_api_call_id}",
181    operation_id = "getSuotarApiCall",
182    tag = "credit-registration-admin",
183    params(("suotar_api_call_id" = Uuid, Path, description = "Study registry call id")),
184    responses(
185        (status = 200, description = "The call and everything it touched", body = SuotarApiCallDetails),
186        (status = 404, description = "No such call")
187    )
188)]
189pub async fn get_suotar_api_call(
190    user: AuthUser,
191    pool: web::Data<PgPool>,
192    suotar_api_call_id: web::Path<Uuid>,
193) -> ControllerResult<web::Json<SuotarApiCallDetails>> {
194    let mut conn = pool.acquire().await?;
195    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
196
197    let call = suotar_api_calls::get_by_id(&mut conn, *suotar_api_call_id).await?;
198    let ledger_references = resolve_ledger_references(
199        &mut conn,
200        &call.credit_registration_ids,
201        &call.request_item_ids,
202    )
203    .await?;
204    let events = models::credit_registration_events::get_by_suotar_api_call_id(
205        &mut conn,
206        *suotar_api_call_id,
207    )
208    .await?
209    .into_iter()
210    .map(|event| SuotarApiCallEvent {
211        id: event.id,
212        credit_registration_id: event.credit_registration_id,
213        created_at: event.created_at,
214        kind: event.kind,
215        from_state: event.from_state,
216        to_state: event.to_state,
217        error_code: event.error_code,
218        message: event.message,
219        details: event.details,
220    })
221    .collect();
222
223    token.authorized_ok(web::Json(SuotarApiCallDetails {
224        request_body_sample: call.request_body_sample.clone(),
225        response_body_sample: call.response_body_sample.clone(),
226        error_message: call.error_message.clone(),
227        call: to_call_row_from_full(&call),
228        ledger_references,
229        events,
230    }))
231}
232
233/// The ledger rows a call named, in the order the call listed them, so the reference table lines up
234/// with the stored body's items.
235async fn resolve_ledger_references(
236    conn: &mut PgConnection,
237    credit_registration_ids: &[Uuid],
238    request_item_ids: &[String],
239) -> Result<Vec<SuotarApiCallLedgerReference>, ControllerError> {
240    if credit_registration_ids.is_empty() {
241        return Ok(Vec::new());
242    }
243    let mut sent_as = models::credit_registration_events::get_request_item_ids_in_call(
244        conn,
245        credit_registration_ids,
246        request_item_ids,
247    )
248    .await?;
249    let rows = credit_registrations::get_admin_facing(
250        conn,
251        &AdminCreditRegistrationFilters {
252            credit_registration_ids: Some(credit_registration_ids),
253            include_superseded: true,
254            ..AdminCreditRegistrationFilters::default()
255        },
256        AdminCreditRegistrationSort::default(),
257        MAX_REFERENCED_ROWS,
258        0,
259    )
260    .await?;
261    let mut by_id: std::collections::HashMap<Uuid, _> =
262        rows.into_iter().map(|row| (row.id, row)).collect();
263    Ok(credit_registration_ids
264        .iter()
265        .filter_map(|id| by_id.remove(id))
266        .map(|row| SuotarApiCallLedgerReference {
267            credit_registration_id: row.id,
268            request_item_id: sent_as.remove(&row.id),
269            user_id: row.user_id,
270            first_name: row.first_name,
271            last_name: row.last_name,
272            email: row.email,
273            student_number: expose_option(&row.student_number).map(str::to_owned),
274            course_id: row.course_id,
275            course_name: row.course_name,
276            state: row.state,
277            error_code: row.error_code,
278        })
279        .collect())
280}
281
282fn to_call_row(call: SuotarApiCallPageRow) -> SuotarApiCallRow {
283    SuotarApiCallRow {
284        id: call.id,
285        endpoint: call.endpoint,
286        started_at: call.started_at,
287        duration_ms: call.duration_ms,
288        http_status: call.http_status,
289        succeeded: call.succeeded,
290        request_item_count: call.request_item_count,
291        ok_item_count: call.ok_item_count,
292        error_item_count: call.error_item_count,
293        pending_item_count: call.pending_item_count,
294        request_level_error_code: call.request_level_error_code,
295        worker_name: call.worker_name,
296        credit_registration_ids: call.credit_registration_ids,
297    }
298}
299
300/// The same summary, from the detail endpoint's full row rather than the bodyless listing one.
301fn to_call_row_from_full(call: &SuotarApiCall) -> SuotarApiCallRow {
302    SuotarApiCallRow {
303        id: call.id,
304        endpoint: call.endpoint,
305        started_at: call.started_at,
306        duration_ms: call.duration_ms,
307        http_status: call.http_status,
308        succeeded: call.succeeded,
309        request_item_count: call.request_item_count,
310        ok_item_count: call.ok_item_count,
311        error_item_count: call.error_item_count,
312        pending_item_count: call.pending_item_count,
313        request_level_error_code: call.request_level_error_code.clone(),
314        worker_name: call.worker_name.clone(),
315        credit_registration_ids: call.credit_registration_ids.clone(),
316    }
317}
318
319pub fn _add_routes(cfg: &mut ServiceConfig) {
320    cfg.route("/suotar-api-calls", web::get().to(list_suotar_api_calls))
321        .route(
322            "/suotar-api-calls/{suotar_api_call_id}",
323            web::get().to(get_suotar_api_call),
324        );
325}