1use 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
23const 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 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 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 pub worker_names: Vec<String>,
52}
53
54#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
57pub struct SuotarApiCallLedgerReference {
58 pub credit_registration_id: Uuid,
59 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#[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 pub message: Option<String>,
85 pub details: Option<serde_json::Value>,
87}
88
89#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
90pub struct SuotarApiCallDetails {
91 pub call: SuotarApiCallRow,
92 pub request_body_sample: Option<serde_json::Value>,
94 pub response_body_sample: Option<serde_json::Value>,
95 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#[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#[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
233async 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
300fn 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}