1use crate::{MetadataExecutor, PgMetaError, PgMetadataConnection};
13use ahri_tre_core::{
14 ApproveCustodianStudyAccessRequest as ApplicationCustodianStudyAccessRequestApproval,
15 CustodianStudyAccessRequest as ApplicationCustodianStudyAccessRequest,
16 CustodianStudyAccessRequestApprovalOutcome as ApplicationCustodianStudyAccessRequestApprovalOutcome,
17 CustodianStudyAccessRequestApprovalPreflight as ApplicationCustodianStudyAccessRequestApprovalPreflight,
18 CustodianStudyAccessRequestDecision as ApplicationCustodianStudyAccessRequestDecision,
19 CustodianStudyAccessRequestPort, CustodianStudyAccessRequestPortError,
20 CustodianStudyAccessRequestQueuePort, CustodianStudyAccessRequestRejectionOutcome,
21 CustodianStudyAccessRequestStatusFilter,
22 CustodianStudyAccessRequests as ApplicationCustodianStudyAccessRequests,
23 RecordCustodianStudyAccessRequestApproval as ApplicationRecordCustodianStudyAccessRequestApproval,
24 RejectCustodianStudyAccessRequest as ApplicationCustodianStudyAccessRequestRejection,
25 RequesterStudyAccessRequest as ApplicationRequesterStudyAccessRequest,
26 RequesterStudyAccessRequestPort, RequesterStudyAccessRequestPortError,
27 RequesterStudyAccessRequestWithdrawalOutcome,
28 RequesterStudyAccessRequests as ApplicationRequesterStudyAccessRequests,
29 StudyAccessRequestDecision as ApplicationStudyAccessRequestDecision,
30 StudyAccessRequestLifecycle as ApplicationStudyAccessRequestLifecycle,
31 StudyAccessRequestSubmissionOutcome as ApplicationAccessRequestSubmissionOutcome,
32 StudyAccessRequestSubmissionPort, StudyAccessRequestSubmissionPortError,
33 SubmitStudyAccessRequest as ApplicationAccessRequestSubmission, SubmittedStudyAccessRequest,
34};
35use ahri_tre_libpq_oauth::{LibpqOAuthConnection, LibpqOAuthRow};
36use ahri_tre_types::StudyId;
37use chrono::{DateTime, Utc};
38use postgres::Row;
39use uuid::Uuid;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct AccessRequestId(pub Uuid);
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub struct AccessRequestDatastoreId(pub Uuid);
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum AccessRequestLifecycle {
52 Pending,
53 Approved,
54 Rejected,
55 Withdrawn,
56 Expired,
57}
58
59impl AccessRequestLifecycle {
60 pub fn decode(value: &str) -> Result<Self, PgMetaError> {
66 match value {
67 "pending" => Ok(Self::Pending),
68 "approved" => Ok(Self::Approved),
69 "rejected" => Ok(Self::Rejected),
70 "withdrawn" => Ok(Self::Withdrawn),
71 "expired" => Ok(Self::Expired),
72 _ => Err(PgMetaError::Decode {
73 field: "request_status",
74 value: value.to_owned(),
75 message: "unknown Study access-request lifecycle".to_string(),
76 }),
77 }
78 }
79
80 pub const fn as_db_str(self) -> &'static str {
82 match self {
83 Self::Pending => "pending",
84 Self::Approved => "approved",
85 Self::Rejected => "rejected",
86 Self::Withdrawn => "withdrawn",
87 Self::Expired => "expired",
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct StudyAccessRequestDecision {
95 pub decided_at: Option<DateTime<Utc>>,
96 pub decided_by: Option<String>,
97 pub reason: Option<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct AccessRequestSubmission {
103 pub datastore_id: AccessRequestDatastoreId,
104 pub study_id: StudyId,
105 pub requester_orcid: String,
106 pub requester_subject: String,
107 pub intended_use: String,
108 pub requester_contact: Option<String>,
109 pub requester_affiliation: Option<String>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct RequesterStudyAccessRequestScope {
115 pub datastore_id: AccessRequestDatastoreId,
116 pub requester_orcid: String,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct RequesterStudyAccessRequest {
122 pub request_id: AccessRequestId,
123 pub datastore_id: AccessRequestDatastoreId,
124 pub study_id: StudyId,
125 pub intended_use: String,
126 pub requester_contact: Option<String>,
127 pub requester_affiliation: Option<String>,
128 pub lifecycle: AccessRequestLifecycle,
129 pub submitted_at: DateTime<Utc>,
130 pub decision: StudyAccessRequestDecision,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct CustodianStudyAccessRequest {
136 pub request_id: AccessRequestId,
137 pub datastore_id: AccessRequestDatastoreId,
138 pub study_id: StudyId,
139 pub requester_orcid: String,
140 pub intended_use: String,
141 pub requester_contact: Option<String>,
142 pub requester_affiliation: Option<String>,
143 pub lifecycle: AccessRequestLifecycle,
144 pub submitted_at: DateTime<Utc>,
145 pub decision: StudyAccessRequestDecision,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct CustodianStudyAccessRequestRejection {
151 pub datastore_id: AccessRequestDatastoreId,
152 pub request_id: AccessRequestId,
153 pub reason: Option<String>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct CustodianStudyAccessRequestScope {
159 pub datastore_id: AccessRequestDatastoreId,
160 pub request_id: AccessRequestId,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct CustodianStudyAccessRequestApproval {
169 pub datastore_id: AccessRequestDatastoreId,
170 pub request_id: AccessRequestId,
171 pub requester_role: String,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum AccessRequestSubmissionOutcome {
177 StudyNotFound,
178 Submitted {
179 request_id: AccessRequestId,
180 submitted_at: DateTime<Utc>,
181 },
182 DuplicatePendingRequest,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum AccessRequestWithdrawalOutcome {
188 Withdrawn(RequesterStudyAccessRequest),
189 NotFound,
190 NotPending,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum AccessRequestCustodianInspection {
196 NotFound,
197 NotCustodian,
198 NotPending,
199 Pending { requester_orcid: String },
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct CustodianAccessRequestInspectionRecord {
205 pub requester_orcid: String,
206 pub lifecycle: AccessRequestLifecycle,
207 pub is_custodian: bool,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum AccessRequestCustodianDecisionOutcome {
213 Decided(Box<CustodianStudyAccessRequest>),
214 NotFound,
215 NotCustodian,
216 NotPending,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum AccessRequestCustodianApprovalOutcome {
222 Approved(Box<CustodianStudyAccessRequest>),
223 NotPending,
224}
225
226const REQUESTER_STUDY_ACCESS_REQUESTS_SQL: &str = "SELECT request_id::text, study_id::text, intended_use, requester_contact, requester_affiliation, request_status::text, to_char(submitted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), CASE WHEN decided_at IS NULL THEN NULL ELSE to_char(decided_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') END, decision_reason FROM public.study_access_requests WHERE datastore_id = $1 AND requester_orcid = $2 ORDER BY submitted_at DESC";
227const REQUESTER_STUDY_ACCESS_REQUEST_BY_ID_SQL: &str = "SELECT request_id::text, study_id::text, intended_use, requester_contact, requester_affiliation, request_status::text, to_char(submitted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), CASE WHEN decided_at IS NULL THEN NULL ELSE to_char(decided_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') END, decision_reason FROM public.study_access_requests WHERE request_id = $1 AND datastore_id = $2 AND requester_orcid = $3";
228const WITHDRAW_REQUESTER_STUDY_ACCESS_REQUEST_SQL: &str = "UPDATE public.study_access_requests SET request_status = 'withdrawn', decided_at = now(), decision_reason = NULL WHERE request_id = $1 AND datastore_id = $2 AND requester_orcid = $3 AND request_status = 'pending' RETURNING request_id::text, study_id::text, intended_use, requester_contact, requester_affiliation, request_status::text, to_char(submitted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), CASE WHEN decided_at IS NULL THEN NULL ELSE to_char(decided_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') END, decision_reason";
229const CUSTODIAN_STUDY_ACCESS_REQUESTS_SQL: &str = "SELECT r.request_id::text, r.study_id::text, r.requester_orcid, r.intended_use, r.requester_contact, r.requester_affiliation, r.request_status::text, to_char(r.submitted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), CASE WHEN r.decided_at IS NULL THEN NULL ELSE to_char(r.decided_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') END, r.decided_by, r.decision_reason FROM public.study_access_requests r WHERE r.datastore_id = $1 AND r.request_status = $2 AND EXISTS (SELECT 1 FROM public.study_custodians c WHERE c.study_id = r.study_id AND c.user_id = current_user) ORDER BY CASE WHEN r.request_status = 'pending' THEN r.submitted_at END ASC NULLS LAST, CASE WHEN r.request_status <> 'pending' THEN r.decided_at END DESC NULLS LAST, r.request_id ASC";
230const CURRENT_USER_HAS_CUSTODIANSHIP_SQL: &str =
231 "SELECT EXISTS (SELECT 1 FROM public.study_custodians c WHERE c.user_id = current_user)::text";
232const REJECT_CUSTODIAN_STUDY_ACCESS_REQUEST_SQL: &str = "UPDATE public.study_access_requests r SET request_status = 'rejected', decided_at = now(), decided_by = current_user, decision_reason = $3 WHERE r.request_id = $1 AND r.datastore_id = $2 AND r.request_status = 'pending' AND EXISTS (SELECT 1 FROM public.study_custodians c WHERE c.study_id = r.study_id AND c.user_id = current_user) RETURNING r.request_id::text, r.study_id::text, r.requester_orcid, r.intended_use, r.requester_contact, r.requester_affiliation, r.request_status::text, to_char(r.submitted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), to_char(r.decided_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), r.decided_by, r.decision_reason";
233const CUSTODIAN_ACCESS_REQUEST_INSPECTION_SQL: &str = "SELECT r.requester_orcid, r.request_status::text, EXISTS (SELECT 1 FROM public.study_custodians c WHERE c.study_id = r.study_id AND c.user_id = current_user)::text FROM public.study_access_requests r WHERE r.request_id = $1 AND r.datastore_id = $2";
234const APPROVE_CUSTODIAN_STUDY_ACCESS_REQUEST_SQL: &str = "WITH authorized_request AS (SELECT r.request_id, r.study_id FROM public.study_access_requests r WHERE r.request_id = $1 AND r.datastore_id = $2 AND r.request_status = 'pending' AND EXISTS (SELECT 1 FROM public.study_custodians c WHERE c.study_id = r.study_id AND c.user_id = current_user)), granted AS (SELECT public.tre_grant_study_access(study_id, $3) FROM authorized_request) UPDATE public.study_access_requests r SET request_status = 'approved', decided_at = now(), decided_by = current_user, decision_reason = NULL FROM authorized_request, granted WHERE r.request_id = authorized_request.request_id RETURNING r.request_id::text, r.study_id::text, r.requester_orcid, r.intended_use, r.requester_contact, r.requester_affiliation, r.request_status::text, to_char(r.submitted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), to_char(r.decided_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), r.decided_by, r.decision_reason";
235const ACCESS_REQUEST_STUDY_EXISTS_SQL: &str =
236 "SELECT public.tre_access_request_study_exists($1)::text";
237const PENDING_ACCESS_REQUEST_EXISTS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM public.study_access_requests WHERE datastore_id = $1 AND study_id = $2 AND requester_orcid = $3 AND request_status = 'pending')::text";
238const INSERT_STUDY_ACCESS_REQUEST_SQL: &str = "INSERT INTO public.study_access_requests (request_id, datastore_id, study_id, requester_orcid, requester_subject, intended_use, requester_contact, requester_affiliation, request_status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending') ON CONFLICT (datastore_id, study_id, requester_orcid) WHERE request_status = 'pending' DO NOTHING RETURNING to_char(submitted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"')";
239
240pub trait StudyAccessRequestMetadataExecutor {
245 fn submit_study_access_request(
248 &mut self,
249 submission: &AccessRequestSubmission,
250 ) -> Result<AccessRequestSubmissionOutcome, PgMetaError>;
251
252 fn list_requester_study_access_requests(
254 &mut self,
255 scope: &RequesterStudyAccessRequestScope,
256 ) -> Result<Vec<RequesterStudyAccessRequest>, PgMetaError>;
257
258 fn get_requester_study_access_request(
260 &mut self,
261 scope: &RequesterStudyAccessRequestScope,
262 request_id: AccessRequestId,
263 ) -> Result<Option<RequesterStudyAccessRequest>, PgMetaError>;
264
265 fn withdraw_requester_study_access_request(
268 &mut self,
269 scope: &RequesterStudyAccessRequestScope,
270 request_id: AccessRequestId,
271 ) -> Result<AccessRequestWithdrawalOutcome, PgMetaError>;
272
273 fn list_custodian_study_access_requests(
276 &mut self,
277 datastore_id: AccessRequestDatastoreId,
278 status: CustodianStudyAccessRequestStatusFilter,
279 ) -> Result<Vec<CustodianStudyAccessRequest>, PgMetaError>;
280
281 fn reject_custodian_study_access_request(
284 &mut self,
285 rejection: &CustodianStudyAccessRequestRejection,
286 ) -> Result<AccessRequestCustodianDecisionOutcome, PgMetaError>;
287
288 fn inspect_custodian_study_access_request(
291 &mut self,
292 scope: &CustodianStudyAccessRequestScope,
293 ) -> Result<AccessRequestCustodianInspection, PgMetaError>;
294
295 fn approve_custodian_study_access_request(
298 &mut self,
299 approval: &CustodianStudyAccessRequestApproval,
300 ) -> Result<AccessRequestCustodianApprovalOutcome, PgMetaError>;
301}
302
303pub struct AuthenticatedCustodianStudyAccessRequestPort {
306 connection: LibpqOAuthConnection,
307}
308
309impl AuthenticatedCustodianStudyAccessRequestPort {
310 pub fn new(connection: LibpqOAuthConnection) -> Self {
311 Self { connection }
312 }
313}
314
315impl CustodianStudyAccessRequestQueuePort for AuthenticatedCustodianStudyAccessRequestPort {
316 fn list_custodian_study_access_requests(
317 &mut self,
318 scope: &ApplicationCustodianStudyAccessRequests,
319 ) -> Result<Vec<ApplicationCustodianStudyAccessRequest>, CustodianStudyAccessRequestPortError>
320 {
321 require_current_user_custodianship(&mut self.connection)?;
322 self.connection
323 .list_custodian_study_access_requests(
324 AccessRequestDatastoreId(scope.datastore_id),
325 scope.status,
326 )
327 .map_err(map_custodian_metadata_port_error)
328 .map(|requests| {
329 requests
330 .into_iter()
331 .map(map_custodian_record_to_application)
332 .collect()
333 })
334 }
335}
336
337impl CustodianStudyAccessRequestPort for AuthenticatedCustodianStudyAccessRequestPort {
338 fn reject_custodian_study_access_request(
339 &mut self,
340 rejection: &ApplicationCustodianStudyAccessRequestRejection,
341 ) -> Result<CustodianStudyAccessRequestRejectionOutcome, CustodianStudyAccessRequestPortError>
342 {
343 reject_authenticated_custodian_study_access_request(&mut self.connection, rejection)
344 }
345
346 fn preflight_custodian_study_access_request_approval(
347 &mut self,
348 approval: &ApplicationCustodianStudyAccessRequestApproval,
349 ) -> Result<
350 ApplicationCustodianStudyAccessRequestApprovalPreflight,
351 CustodianStudyAccessRequestPortError,
352 > {
353 preflight_authenticated_custodian_study_access_request_approval(
354 &mut self.connection,
355 approval,
356 )
357 }
358
359 fn record_custodian_study_access_request_approval(
360 &mut self,
361 approval: &ApplicationRecordCustodianStudyAccessRequestApproval,
362 ) -> Result<
363 ApplicationCustodianStudyAccessRequestApprovalOutcome,
364 CustodianStudyAccessRequestPortError,
365 > {
366 record_authenticated_custodian_study_access_request_approval(&mut self.connection, approval)
367 }
368}
369
370pub struct BorrowedAuthenticatedCustodianStudyAccessRequestPort<'a> {
373 context: Option<ahri_tre_observability::CorrelationContext>,
374 connection: &'a mut LibpqOAuthConnection,
375}
376
377impl<'a> BorrowedAuthenticatedCustodianStudyAccessRequestPort<'a> {
378 pub fn new(connection: &'a mut LibpqOAuthConnection) -> Self {
379 Self {
380 connection,
381 context: None,
382 }
383 }
384 pub fn with_correlation(mut self, context: ahri_tre_observability::CorrelationContext) -> Self {
385 self.context = Some(context);
386 self
387 }
388}
389
390impl CustodianStudyAccessRequestQueuePort
391 for BorrowedAuthenticatedCustodianStudyAccessRequestPort<'_>
392{
393 fn list_custodian_study_access_requests(
394 &mut self,
395 scope: &ApplicationCustodianStudyAccessRequests,
396 ) -> Result<Vec<ApplicationCustodianStudyAccessRequest>, CustodianStudyAccessRequestPortError>
397 {
398 let span = self
399 .context
400 .map(|context| context.span(ahri_tre_observability::Stage::MetadataQuery));
401 let result = (|| {
402 require_current_user_custodianship(self.connection)?;
403 self.connection
404 .list_custodian_study_access_requests(
405 AccessRequestDatastoreId(scope.datastore_id),
406 scope.status,
407 )
408 .map_err(map_custodian_metadata_port_error)
409 .map(|requests| {
410 requests
411 .into_iter()
412 .map(map_custodian_record_to_application)
413 .collect()
414 })
415 })();
416 finish_custodian_metadata(span, result.as_ref().err(), false, false);
417 result
418 }
419}
420
421impl CustodianStudyAccessRequestPort for BorrowedAuthenticatedCustodianStudyAccessRequestPort<'_> {
422 fn reject_custodian_study_access_request(
423 &mut self,
424 rejection: &ApplicationCustodianStudyAccessRequestRejection,
425 ) -> Result<CustodianStudyAccessRequestRejectionOutcome, CustodianStudyAccessRequestPortError>
426 {
427 let span = self
428 .context
429 .map(|context| context.span(ahri_tre_observability::Stage::MetadataDecision));
430 let result =
431 reject_authenticated_custodian_study_access_request(self.connection, rejection);
432 finish_custodian_metadata(
433 span,
434 result.as_ref().err(),
435 matches!(
436 &result,
437 Ok(CustodianStudyAccessRequestRejectionOutcome::NotCustodian)
438 ),
439 matches!(
440 &result,
441 Ok(CustodianStudyAccessRequestRejectionOutcome::NotFound
442 | CustodianStudyAccessRequestRejectionOutcome::NotPending)
443 ),
444 );
445 result
446 }
447
448 fn preflight_custodian_study_access_request_approval(
449 &mut self,
450 approval: &ApplicationCustodianStudyAccessRequestApproval,
451 ) -> Result<
452 ApplicationCustodianStudyAccessRequestApprovalPreflight,
453 CustodianStudyAccessRequestPortError,
454 > {
455 let span = self
456 .context
457 .map(|context| context.span(ahri_tre_observability::Stage::MetadataPreflight));
458 let result = preflight_authenticated_custodian_study_access_request_approval(
459 self.connection,
460 approval,
461 );
462 finish_custodian_metadata(
463 span,
464 result.as_ref().err(),
465 matches!(
466 &result,
467 Ok(ApplicationCustodianStudyAccessRequestApprovalPreflight::NotCustodian)
468 ),
469 matches!(
470 &result,
471 Ok(
472 ApplicationCustodianStudyAccessRequestApprovalPreflight::NotFound
473 | ApplicationCustodianStudyAccessRequestApprovalPreflight::NotPending
474 )
475 ),
476 );
477 result
478 }
479
480 fn record_custodian_study_access_request_approval(
481 &mut self,
482 approval: &ApplicationRecordCustodianStudyAccessRequestApproval,
483 ) -> Result<
484 ApplicationCustodianStudyAccessRequestApprovalOutcome,
485 CustodianStudyAccessRequestPortError,
486 > {
487 let span = self
488 .context
489 .map(|context| context.span(ahri_tre_observability::Stage::MetadataDecision));
490 let result =
491 record_authenticated_custodian_study_access_request_approval(self.connection, approval);
492 finish_custodian_metadata(
493 span,
494 result.as_ref().err(),
495 false,
496 matches!(
497 &result,
498 Ok(ApplicationCustodianStudyAccessRequestApprovalOutcome::NotPending)
499 ),
500 );
501 result
502 }
503}
504
505fn finish_custodian_metadata(
506 span: Option<ahri_tre_observability::OperationSpan>,
507 error: Option<&CustodianStudyAccessRequestPortError>,
508 denied: bool,
509 rejected: bool,
510) {
511 use ahri_tre_observability::{FailureCategory, Outcome};
512 let Some(span) = span else {
513 return;
514 };
515 let (outcome, category) = match error {
516 Some(CustodianStudyAccessRequestPortError::AuthenticatedTreUserRequired) => {
517 (Outcome::Rejected, Some(FailureCategory::Authentication))
518 }
519 Some(CustodianStudyAccessRequestPortError::CustodianshipRequired) => {
520 (Outcome::Rejected, Some(FailureCategory::Authorization))
521 }
522 Some(CustodianStudyAccessRequestPortError::DatastoreUnavailable) => {
523 (Outcome::Unavailable, Some(FailureCategory::Query))
524 }
525 Some(CustodianStudyAccessRequestPortError::AuthorityUnavailable) => {
526 (Outcome::Unavailable, Some(FailureCategory::Dependency))
527 }
528 Some(CustodianStudyAccessRequestPortError::InvalidLifecycleState) => (
529 Outcome::InternalFailure,
530 Some(FailureCategory::Compatibility),
531 ),
532 Some(_) => (Outcome::Rejected, None),
533 None if denied => (Outcome::Rejected, Some(FailureCategory::Authorization)),
534 None if rejected => (Outcome::Rejected, None),
535 None => (Outcome::Success, None),
536 };
537 span.finish(outcome, category);
538}
539
540fn reject_authenticated_custodian_study_access_request(
541 connection: &mut LibpqOAuthConnection,
542 rejection: &ApplicationCustodianStudyAccessRequestRejection,
543) -> Result<CustodianStudyAccessRequestRejectionOutcome, CustodianStudyAccessRequestPortError> {
544 match connection
545 .reject_custodian_study_access_request(&CustodianStudyAccessRequestRejection {
546 datastore_id: AccessRequestDatastoreId(rejection.datastore_id),
547 request_id: AccessRequestId(rejection.request_id),
548 reason: rejection.reason.clone(),
549 })
550 .map_err(map_custodian_metadata_port_error)?
551 {
552 AccessRequestCustodianDecisionOutcome::Decided(request) => {
553 Ok(CustodianStudyAccessRequestRejectionOutcome::Rejected(
554 Box::new(map_custodian_record_to_application(*request)),
555 ))
556 }
557 AccessRequestCustodianDecisionOutcome::NotFound => {
558 Ok(CustodianStudyAccessRequestRejectionOutcome::NotFound)
559 }
560 AccessRequestCustodianDecisionOutcome::NotCustodian => {
561 Ok(CustodianStudyAccessRequestRejectionOutcome::NotCustodian)
562 }
563 AccessRequestCustodianDecisionOutcome::NotPending => {
564 Ok(CustodianStudyAccessRequestRejectionOutcome::NotPending)
565 }
566 }
567}
568
569fn preflight_authenticated_custodian_study_access_request_approval(
570 connection: &mut LibpqOAuthConnection,
571 approval: &ApplicationCustodianStudyAccessRequestApproval,
572) -> Result<
573 ApplicationCustodianStudyAccessRequestApprovalPreflight,
574 CustodianStudyAccessRequestPortError,
575> {
576 match connection
577 .inspect_custodian_study_access_request(&CustodianStudyAccessRequestScope {
578 datastore_id: AccessRequestDatastoreId(approval.datastore_id),
579 request_id: AccessRequestId(approval.request_id),
580 })
581 .map_err(map_custodian_metadata_port_error)?
582 {
583 AccessRequestCustodianInspection::Pending { requester_orcid } => Ok(
584 ApplicationCustodianStudyAccessRequestApprovalPreflight::Pending { requester_orcid },
585 ),
586 AccessRequestCustodianInspection::NotFound => {
587 Ok(ApplicationCustodianStudyAccessRequestApprovalPreflight::NotFound)
588 }
589 AccessRequestCustodianInspection::NotCustodian => {
590 Ok(ApplicationCustodianStudyAccessRequestApprovalPreflight::NotCustodian)
591 }
592 AccessRequestCustodianInspection::NotPending => {
593 Ok(ApplicationCustodianStudyAccessRequestApprovalPreflight::NotPending)
594 }
595 }
596}
597
598fn record_authenticated_custodian_study_access_request_approval(
599 connection: &mut LibpqOAuthConnection,
600 approval: &ApplicationRecordCustodianStudyAccessRequestApproval,
601) -> Result<
602 ApplicationCustodianStudyAccessRequestApprovalOutcome,
603 CustodianStudyAccessRequestPortError,
604> {
605 match connection
606 .approve_custodian_study_access_request(&CustodianStudyAccessRequestApproval {
607 datastore_id: AccessRequestDatastoreId(approval.datastore_id),
608 request_id: AccessRequestId(approval.request_id),
609 requester_role: approval.requester_role.clone(),
610 })
611 .map_err(map_custodian_metadata_port_error)?
612 {
613 AccessRequestCustodianApprovalOutcome::Approved(request) => Ok(
614 ApplicationCustodianStudyAccessRequestApprovalOutcome::Approved(Box::new(
615 map_custodian_record_to_application(*request),
616 )),
617 ),
618 AccessRequestCustodianApprovalOutcome::NotPending => {
619 Ok(ApplicationCustodianStudyAccessRequestApprovalOutcome::NotPending)
620 }
621 }
622}
623
624fn require_current_user_custodianship<E>(
625 executor: &mut E,
626) -> Result<(), CustodianStudyAccessRequestPortError>
627where
628 E: MetadataExecutor,
629 E::Row: AccessRequestRow,
630{
631 let row = executor
632 .query_one(
633 "authorize custodian Study access-request queue",
634 CURRENT_USER_HAS_CUSTODIANSHIP_SQL,
635 &[],
636 )
637 .map_err(map_custodian_metadata_port_error)?;
638 match row
639 .optional_access_request_text(0, "has_custodianship")
640 .map_err(map_custodian_metadata_port_error)?
641 .as_deref()
642 {
643 Some("true") => Ok(()),
644 Some("false") => Err(CustodianStudyAccessRequestPortError::CustodianshipRequired),
645 _ => Err(CustodianStudyAccessRequestPortError::InvalidLifecycleState),
646 }
647}
648
649impl StudyAccessRequestMetadataExecutor for PgMetadataConnection {
650 fn submit_study_access_request(
651 &mut self,
652 submission: &AccessRequestSubmission,
653 ) -> Result<AccessRequestSubmissionOutcome, PgMetaError> {
654 let result = submit_study_access_request_with_executor(self, submission, || {
655 AccessRequestId(Uuid::new_v4())
656 });
657 if let Err(error) = &result {
658 self.observe_error(error);
659 }
660 result
661 }
662
663 fn list_requester_study_access_requests(
664 &mut self,
665 scope: &RequesterStudyAccessRequestScope,
666 ) -> Result<Vec<RequesterStudyAccessRequest>, PgMetaError> {
667 let result = list_requester_study_access_requests_with_executor(self, scope);
668 if let Err(error) = &result {
669 self.observe_error(error);
670 }
671 result
672 }
673
674 fn get_requester_study_access_request(
675 &mut self,
676 scope: &RequesterStudyAccessRequestScope,
677 request_id: AccessRequestId,
678 ) -> Result<Option<RequesterStudyAccessRequest>, PgMetaError> {
679 let result = get_requester_study_access_request_with_executor(self, scope, request_id);
680 if let Err(error) = &result {
681 self.observe_error(error);
682 }
683 result
684 }
685
686 fn withdraw_requester_study_access_request(
687 &mut self,
688 scope: &RequesterStudyAccessRequestScope,
689 request_id: AccessRequestId,
690 ) -> Result<AccessRequestWithdrawalOutcome, PgMetaError> {
691 let result = withdraw_requester_study_access_request_with_executor(self, scope, request_id);
692 if let Err(error) = &result {
693 self.observe_error(error);
694 }
695 result
696 }
697
698 fn list_custodian_study_access_requests(
699 &mut self,
700 datastore_id: AccessRequestDatastoreId,
701 status: CustodianStudyAccessRequestStatusFilter,
702 ) -> Result<Vec<CustodianStudyAccessRequest>, PgMetaError> {
703 let result = list_custodian_study_access_requests_with_executor(self, datastore_id, status);
704 if let Err(error) = &result {
705 self.observe_error(error);
706 }
707 result
708 }
709
710 fn reject_custodian_study_access_request(
711 &mut self,
712 rejection: &CustodianStudyAccessRequestRejection,
713 ) -> Result<AccessRequestCustodianDecisionOutcome, PgMetaError> {
714 let result = reject_custodian_study_access_request_with_executor(self, rejection);
715 if let Err(error) = &result {
716 self.observe_error(error);
717 }
718 result
719 }
720
721 fn inspect_custodian_study_access_request(
722 &mut self,
723 scope: &CustodianStudyAccessRequestScope,
724 ) -> Result<AccessRequestCustodianInspection, PgMetaError> {
725 let result = inspect_custodian_approval_preflight_with_executor(self, scope);
726 if let Err(error) = &result {
727 self.observe_error(error);
728 }
729 result
730 }
731
732 fn approve_custodian_study_access_request(
733 &mut self,
734 approval: &CustodianStudyAccessRequestApproval,
735 ) -> Result<AccessRequestCustodianApprovalOutcome, PgMetaError> {
736 let result = approve_custodian_study_access_request_with_executor(self, approval);
737 if let Err(error) = &result {
738 self.observe_error(error);
739 }
740 result
741 }
742}
743
744impl StudyAccessRequestSubmissionPort for PgMetadataConnection {
745 fn submit_study_access_request(
746 &mut self,
747 submission: &ApplicationAccessRequestSubmission,
748 ) -> Result<ApplicationAccessRequestSubmissionOutcome, StudyAccessRequestSubmissionPortError>
749 {
750 submit_application_study_access_request(self, submission)
751 }
752}
753
754impl RequesterStudyAccessRequestPort for PgMetadataConnection {
755 fn list_requester_study_access_requests(
756 &mut self,
757 scope: &ApplicationRequesterStudyAccessRequests,
758 ) -> Result<Vec<ApplicationRequesterStudyAccessRequest>, RequesterStudyAccessRequestPortError>
759 {
760 list_application_requester_study_access_requests(self, scope)
761 }
762
763 fn get_requester_study_access_request(
764 &mut self,
765 scope: &ApplicationRequesterStudyAccessRequests,
766 request_id: Uuid,
767 ) -> Result<Option<ApplicationRequesterStudyAccessRequest>, RequesterStudyAccessRequestPortError>
768 {
769 get_application_requester_study_access_request(self, scope, request_id)
770 }
771
772 fn withdraw_requester_study_access_request(
773 &mut self,
774 scope: &ApplicationRequesterStudyAccessRequests,
775 request_id: Uuid,
776 ) -> Result<RequesterStudyAccessRequestWithdrawalOutcome, RequesterStudyAccessRequestPortError>
777 {
778 withdraw_application_requester_study_access_request(self, scope, request_id)
779 }
780}
781
782impl StudyAccessRequestMetadataExecutor for LibpqOAuthConnection {
783 fn submit_study_access_request(
784 &mut self,
785 submission: &AccessRequestSubmission,
786 ) -> Result<AccessRequestSubmissionOutcome, PgMetaError> {
787 submit_study_access_request_with_executor(self, submission, || {
788 AccessRequestId(Uuid::new_v4())
789 })
790 }
791
792 fn list_requester_study_access_requests(
793 &mut self,
794 scope: &RequesterStudyAccessRequestScope,
795 ) -> Result<Vec<RequesterStudyAccessRequest>, PgMetaError> {
796 list_requester_study_access_requests_with_executor(self, scope)
797 }
798
799 fn get_requester_study_access_request(
800 &mut self,
801 scope: &RequesterStudyAccessRequestScope,
802 request_id: AccessRequestId,
803 ) -> Result<Option<RequesterStudyAccessRequest>, PgMetaError> {
804 get_requester_study_access_request_with_executor(self, scope, request_id)
805 }
806
807 fn withdraw_requester_study_access_request(
808 &mut self,
809 scope: &RequesterStudyAccessRequestScope,
810 request_id: AccessRequestId,
811 ) -> Result<AccessRequestWithdrawalOutcome, PgMetaError> {
812 withdraw_requester_study_access_request_with_executor(self, scope, request_id)
813 }
814
815 fn list_custodian_study_access_requests(
816 &mut self,
817 datastore_id: AccessRequestDatastoreId,
818 status: CustodianStudyAccessRequestStatusFilter,
819 ) -> Result<Vec<CustodianStudyAccessRequest>, PgMetaError> {
820 list_custodian_study_access_requests_with_executor(self, datastore_id, status)
821 }
822
823 fn reject_custodian_study_access_request(
824 &mut self,
825 rejection: &CustodianStudyAccessRequestRejection,
826 ) -> Result<AccessRequestCustodianDecisionOutcome, PgMetaError> {
827 reject_custodian_study_access_request_with_executor(self, rejection)
828 }
829
830 fn inspect_custodian_study_access_request(
831 &mut self,
832 scope: &CustodianStudyAccessRequestScope,
833 ) -> Result<AccessRequestCustodianInspection, PgMetaError> {
834 inspect_custodian_approval_preflight_with_executor(self, scope)
835 }
836
837 fn approve_custodian_study_access_request(
838 &mut self,
839 approval: &CustodianStudyAccessRequestApproval,
840 ) -> Result<AccessRequestCustodianApprovalOutcome, PgMetaError> {
841 approve_custodian_study_access_request_with_executor(self, approval)
842 }
843}
844
845fn submit_application_study_access_request<E>(
846 executor: &mut E,
847 submission: &ApplicationAccessRequestSubmission,
848) -> Result<ApplicationAccessRequestSubmissionOutcome, StudyAccessRequestSubmissionPortError>
849where
850 E: MetadataExecutor,
851 E::Row: AccessRequestRow,
852{
853 match submit_study_access_request_with_executor(
854 executor,
855 &AccessRequestSubmission {
856 datastore_id: AccessRequestDatastoreId(submission.datastore_id),
857 study_id: submission.study_id,
858 requester_orcid: submission.requester_orcid.clone(),
859 requester_subject: submission.requester_subject.clone(),
860 intended_use: submission.intended_use.clone(),
861 requester_contact: submission.contact.clone(),
862 requester_affiliation: submission.affiliation.clone(),
863 },
864 || AccessRequestId(Uuid::new_v4()),
865 )
866 .map_err(|_| StudyAccessRequestSubmissionPortError::DatastoreUnavailable)?
867 {
868 AccessRequestSubmissionOutcome::StudyNotFound => {
869 Ok(ApplicationAccessRequestSubmissionOutcome::StudyNotFound)
870 }
871 AccessRequestSubmissionOutcome::DuplicatePendingRequest => {
872 Ok(ApplicationAccessRequestSubmissionOutcome::DuplicatePendingRequest)
873 }
874 AccessRequestSubmissionOutcome::Submitted {
875 request_id,
876 submitted_at,
877 } => Ok(ApplicationAccessRequestSubmissionOutcome::Submitted(
878 SubmittedStudyAccessRequest {
879 request_id: request_id.0,
880 submitted_at,
881 },
882 )),
883 }
884}
885
886fn list_application_requester_study_access_requests<E>(
887 executor: &mut E,
888 scope: &ApplicationRequesterStudyAccessRequests,
889) -> Result<Vec<ApplicationRequesterStudyAccessRequest>, RequesterStudyAccessRequestPortError>
890where
891 E: MetadataExecutor,
892 E::Row: AccessRequestRow,
893{
894 let requests = list_requester_study_access_requests_with_executor(
895 executor,
896 &RequesterStudyAccessRequestScope {
897 datastore_id: AccessRequestDatastoreId(scope.datastore_id),
898 requester_orcid: scope.requester_orcid.clone(),
899 },
900 )
901 .map_err(map_requester_metadata_port_error)?;
902 Ok(requests
903 .into_iter()
904 .map(map_requester_record_to_application)
905 .collect())
906}
907
908fn get_application_requester_study_access_request<E>(
909 executor: &mut E,
910 scope: &ApplicationRequesterStudyAccessRequests,
911 request_id: Uuid,
912) -> Result<Option<ApplicationRequesterStudyAccessRequest>, RequesterStudyAccessRequestPortError>
913where
914 E: MetadataExecutor,
915 E::Row: AccessRequestRow,
916{
917 let request = get_requester_study_access_request_with_executor(
918 executor,
919 &RequesterStudyAccessRequestScope {
920 datastore_id: AccessRequestDatastoreId(scope.datastore_id),
921 requester_orcid: scope.requester_orcid.clone(),
922 },
923 AccessRequestId(request_id),
924 )
925 .map_err(map_requester_metadata_port_error)?;
926 Ok(request.map(map_requester_record_to_application))
927}
928
929fn withdraw_application_requester_study_access_request<E>(
930 executor: &mut E,
931 scope: &ApplicationRequesterStudyAccessRequests,
932 request_id: Uuid,
933) -> Result<RequesterStudyAccessRequestWithdrawalOutcome, RequesterStudyAccessRequestPortError>
934where
935 E: MetadataExecutor,
936 E::Row: AccessRequestRow,
937{
938 match withdraw_requester_study_access_request_with_executor(
939 executor,
940 &RequesterStudyAccessRequestScope {
941 datastore_id: AccessRequestDatastoreId(scope.datastore_id),
942 requester_orcid: scope.requester_orcid.clone(),
943 },
944 AccessRequestId(request_id),
945 )
946 .map_err(map_requester_metadata_port_error)?
947 {
948 AccessRequestWithdrawalOutcome::Withdrawn(request) => {
949 Ok(RequesterStudyAccessRequestWithdrawalOutcome::Withdrawn(
950 map_requester_record_to_application(request),
951 ))
952 }
953 AccessRequestWithdrawalOutcome::NotFound => {
954 Ok(RequesterStudyAccessRequestWithdrawalOutcome::NotFound)
955 }
956 AccessRequestWithdrawalOutcome::NotPending => {
957 Ok(RequesterStudyAccessRequestWithdrawalOutcome::NotPending)
958 }
959 }
960}
961
962fn map_requester_record_to_application(
963 request: RequesterStudyAccessRequest,
964) -> ApplicationRequesterStudyAccessRequest {
965 ApplicationRequesterStudyAccessRequest {
966 request_id: request.request_id.0,
967 datastore_id: request.datastore_id.0,
968 study_id: request.study_id,
969 intended_use: request.intended_use,
970 contact: request.requester_contact,
971 affiliation: request.requester_affiliation,
972 lifecycle: match request.lifecycle {
973 AccessRequestLifecycle::Pending => ApplicationStudyAccessRequestLifecycle::Pending,
974 AccessRequestLifecycle::Approved => ApplicationStudyAccessRequestLifecycle::Approved,
975 AccessRequestLifecycle::Rejected => ApplicationStudyAccessRequestLifecycle::Rejected,
976 AccessRequestLifecycle::Withdrawn => ApplicationStudyAccessRequestLifecycle::Withdrawn,
977 AccessRequestLifecycle::Expired => ApplicationStudyAccessRequestLifecycle::Expired,
978 },
979 submitted_at: request.submitted_at,
980 decision: ApplicationStudyAccessRequestDecision {
981 decided_at: request.decision.decided_at,
982 reason: request.decision.reason,
983 },
984 }
985}
986
987fn map_requester_metadata_port_error(error: PgMetaError) -> RequesterStudyAccessRequestPortError {
988 match error {
989 PgMetaError::Decode {
990 field: "request_status",
991 ..
992 } => RequesterStudyAccessRequestPortError::InvalidLifecycleState,
993 _ => RequesterStudyAccessRequestPortError::DatastoreUnavailable,
994 }
995}
996
997fn map_custodian_record_to_application(
998 request: CustodianStudyAccessRequest,
999) -> ApplicationCustodianStudyAccessRequest {
1000 ApplicationCustodianStudyAccessRequest {
1001 request_id: request.request_id.0,
1002 datastore_id: request.datastore_id.0,
1003 study_id: request.study_id,
1004 requester_orcid: request.requester_orcid,
1005 intended_use: request.intended_use,
1006 contact: request.requester_contact,
1007 affiliation: request.requester_affiliation,
1008 lifecycle: match request.lifecycle {
1009 AccessRequestLifecycle::Pending => ApplicationStudyAccessRequestLifecycle::Pending,
1010 AccessRequestLifecycle::Approved => ApplicationStudyAccessRequestLifecycle::Approved,
1011 AccessRequestLifecycle::Rejected => ApplicationStudyAccessRequestLifecycle::Rejected,
1012 AccessRequestLifecycle::Withdrawn => ApplicationStudyAccessRequestLifecycle::Withdrawn,
1013 AccessRequestLifecycle::Expired => ApplicationStudyAccessRequestLifecycle::Expired,
1014 },
1015 submitted_at: request.submitted_at,
1016 decision: ApplicationCustodianStudyAccessRequestDecision {
1017 decided_at: request.decision.decided_at,
1018 decided_by: request.decision.decided_by,
1019 reason: request.decision.reason,
1020 },
1021 }
1022}
1023
1024fn map_custodian_metadata_port_error(error: PgMetaError) -> CustodianStudyAccessRequestPortError {
1025 match error {
1026 PgMetaError::Decode { .. } => CustodianStudyAccessRequestPortError::InvalidLifecycleState,
1027 _ => CustodianStudyAccessRequestPortError::DatastoreUnavailable,
1028 }
1029}
1030
1031pub(crate) trait AccessRequestRow {
1036 fn required_access_request_text(
1038 &self,
1039 index: usize,
1040 field: &'static str,
1041 ) -> Result<String, PgMetaError>;
1042
1043 fn optional_access_request_text(
1045 &self,
1046 index: usize,
1047 field: &'static str,
1048 ) -> Result<Option<String>, PgMetaError>;
1049}
1050
1051impl AccessRequestRow for Row {
1052 fn required_access_request_text(
1053 &self,
1054 index: usize,
1055 field: &'static str,
1056 ) -> Result<String, PgMetaError> {
1057 self.try_get::<_, Option<String>>(index)
1058 .map_err(PgMetaError::Query)?
1059 .ok_or_else(|| missing_required_field(field))
1060 }
1061
1062 fn optional_access_request_text(
1063 &self,
1064 index: usize,
1065 _field: &'static str,
1066 ) -> Result<Option<String>, PgMetaError> {
1067 self.try_get(index).map_err(PgMetaError::Query)
1068 }
1069}
1070
1071impl AccessRequestRow for LibpqOAuthRow {
1072 fn required_access_request_text(
1073 &self,
1074 index: usize,
1075 field: &'static str,
1076 ) -> Result<String, PgMetaError> {
1077 self.get(index)
1078 .map(ToOwned::to_owned)
1079 .ok_or_else(|| missing_required_field(field))
1080 }
1081
1082 fn optional_access_request_text(
1083 &self,
1084 index: usize,
1085 _field: &'static str,
1086 ) -> Result<Option<String>, PgMetaError> {
1087 Ok(self.get(index).map(ToOwned::to_owned))
1088 }
1089}
1090
1091fn submit_study_access_request_with_executor<E, NewRequestId>(
1092 executor: &mut E,
1093 submission: &AccessRequestSubmission,
1094 new_request_id: NewRequestId,
1095) -> Result<AccessRequestSubmissionOutcome, PgMetaError>
1096where
1097 E: MetadataExecutor,
1098 E::Row: AccessRequestRow,
1099 NewRequestId: FnOnce() -> AccessRequestId,
1100{
1101 let study_exists = required_bool(
1102 &executor.query_one(
1103 "check selected Study for access request submission",
1104 ACCESS_REQUEST_STUDY_EXISTS_SQL,
1105 &[&submission.study_id.0],
1106 )?,
1107 "study_exists",
1108 )?;
1109 if !study_exists {
1110 return Ok(AccessRequestSubmissionOutcome::StudyNotFound);
1111 }
1112
1113 let duplicate_exists = required_bool(
1114 &executor.query_one(
1115 "check pending Study access request duplicate",
1116 PENDING_ACCESS_REQUEST_EXISTS_SQL,
1117 &[
1118 &submission.datastore_id.0,
1119 &submission.study_id.0,
1120 &submission.requester_orcid,
1121 ],
1122 )?,
1123 "pending_request_exists",
1124 )?;
1125 if duplicate_exists {
1126 return Ok(AccessRequestSubmissionOutcome::DuplicatePendingRequest);
1127 }
1128
1129 let request_id = new_request_id();
1130
1131 match executor.query_optional(
1132 "submit pending Study access request",
1133 INSERT_STUDY_ACCESS_REQUEST_SQL,
1134 &[
1135 &request_id.0,
1136 &submission.datastore_id.0,
1137 &submission.study_id.0,
1138 &submission.requester_orcid,
1139 &submission.requester_subject,
1140 &submission.intended_use,
1141 &submission.requester_contact,
1142 &submission.requester_affiliation,
1143 ],
1144 )? {
1145 Some(row) => Ok(AccessRequestSubmissionOutcome::Submitted {
1146 request_id,
1147 submitted_at: required_timestamp(&row, 0, "submitted_at")?,
1148 }),
1149 None => Ok(AccessRequestSubmissionOutcome::DuplicatePendingRequest),
1150 }
1151}
1152
1153fn list_requester_study_access_requests_with_executor<E>(
1154 executor: &mut E,
1155 scope: &RequesterStudyAccessRequestScope,
1156) -> Result<Vec<RequesterStudyAccessRequest>, PgMetaError>
1157where
1158 E: MetadataExecutor,
1159 E::Row: AccessRequestRow,
1160{
1161 executor
1162 .query_many(
1163 "list requester Study access requests",
1164 REQUESTER_STUDY_ACCESS_REQUESTS_SQL,
1165 &[&scope.datastore_id.0, &scope.requester_orcid],
1166 )?
1167 .iter()
1168 .map(|row| decode_requester_study_access_request(row, scope.datastore_id))
1169 .collect()
1170}
1171
1172fn get_requester_study_access_request_with_executor<E>(
1173 executor: &mut E,
1174 scope: &RequesterStudyAccessRequestScope,
1175 request_id: AccessRequestId,
1176) -> Result<Option<RequesterStudyAccessRequest>, PgMetaError>
1177where
1178 E: MetadataExecutor,
1179 E::Row: AccessRequestRow,
1180{
1181 executor
1182 .query_optional(
1183 "get requester Study access request",
1184 REQUESTER_STUDY_ACCESS_REQUEST_BY_ID_SQL,
1185 &[&request_id.0, &scope.datastore_id.0, &scope.requester_orcid],
1186 )?
1187 .map(|row| decode_requester_study_access_request(&row, scope.datastore_id))
1188 .transpose()
1189}
1190
1191fn withdraw_requester_study_access_request_with_executor<E>(
1192 executor: &mut E,
1193 scope: &RequesterStudyAccessRequestScope,
1194 request_id: AccessRequestId,
1195) -> Result<AccessRequestWithdrawalOutcome, PgMetaError>
1196where
1197 E: MetadataExecutor,
1198 E::Row: AccessRequestRow,
1199{
1200 let withdrawn = executor
1201 .query_optional(
1202 "withdraw pending requester Study access request",
1203 WITHDRAW_REQUESTER_STUDY_ACCESS_REQUEST_SQL,
1204 &[&request_id.0, &scope.datastore_id.0, &scope.requester_orcid],
1205 )?
1206 .map(|row| decode_requester_study_access_request(&row, scope.datastore_id))
1207 .transpose()?;
1208 if let Some(withdrawn) = withdrawn {
1209 return Ok(AccessRequestWithdrawalOutcome::Withdrawn(withdrawn));
1210 }
1211
1212 let existing = get_requester_study_access_request_with_executor(executor, scope, request_id)?;
1213 Ok(classify_requester_withdrawal(None, existing))
1214}
1215
1216fn list_custodian_study_access_requests_with_executor<E>(
1217 executor: &mut E,
1218 datastore_id: AccessRequestDatastoreId,
1219 status: CustodianStudyAccessRequestStatusFilter,
1220) -> Result<Vec<CustodianStudyAccessRequest>, PgMetaError>
1221where
1222 E: MetadataExecutor,
1223 E::Row: AccessRequestRow,
1224{
1225 executor
1226 .query_many(
1227 "list custodian Study access requests by status",
1228 CUSTODIAN_STUDY_ACCESS_REQUESTS_SQL,
1229 &[&datastore_id.0, &status.as_str()],
1230 )?
1231 .iter()
1232 .map(|row| decode_custodian_study_access_request(row, datastore_id))
1233 .collect()
1234}
1235
1236fn reject_custodian_study_access_request_with_executor<E>(
1237 executor: &mut E,
1238 rejection: &CustodianStudyAccessRequestRejection,
1239) -> Result<AccessRequestCustodianDecisionOutcome, PgMetaError>
1240where
1241 E: MetadataExecutor,
1242 E::Row: AccessRequestRow,
1243{
1244 let decided = executor
1245 .query_optional(
1246 "reject pending custodian Study access request",
1247 REJECT_CUSTODIAN_STUDY_ACCESS_REQUEST_SQL,
1248 &[
1249 &rejection.request_id.0,
1250 &rejection.datastore_id.0,
1251 &rejection.reason,
1252 ],
1253 )?
1254 .map(|row| decode_custodian_study_access_request(&row, rejection.datastore_id))
1255 .transpose()?;
1256 if let Some(decided) = decided {
1257 return Ok(AccessRequestCustodianDecisionOutcome::Decided(Box::new(
1258 decided,
1259 )));
1260 }
1261
1262 let inspection = inspect_custodian_study_access_request_with_executor(
1263 executor,
1264 &CustodianStudyAccessRequestScope {
1265 datastore_id: rejection.datastore_id,
1266 request_id: rejection.request_id,
1267 },
1268 )?;
1269 Ok(classify_custodian_decision(None, inspection))
1270}
1271
1272fn inspect_custodian_study_access_request_with_executor<E>(
1273 executor: &mut E,
1274 scope: &CustodianStudyAccessRequestScope,
1275) -> Result<AccessRequestCustodianInspection, PgMetaError>
1276where
1277 E: MetadataExecutor,
1278 E::Row: AccessRequestRow,
1279{
1280 let record = executor
1281 .query_optional(
1282 "inspect custodian Study access request",
1283 CUSTODIAN_ACCESS_REQUEST_INSPECTION_SQL,
1284 &[&scope.request_id.0, &scope.datastore_id.0],
1285 )?
1286 .map(decode_custodian_access_request_inspection)
1287 .transpose()?;
1288 Ok(classify_custodian_inspection(record))
1289}
1290
1291fn inspect_custodian_approval_preflight_with_executor<E>(
1292 executor: &mut E,
1293 scope: &CustodianStudyAccessRequestScope,
1294) -> Result<AccessRequestCustodianInspection, PgMetaError>
1295where
1296 E: MetadataExecutor,
1297 E::Row: AccessRequestRow,
1298{
1299 executor
1300 .query_optional(
1301 "inspect custodian Study access request",
1302 CUSTODIAN_ACCESS_REQUEST_INSPECTION_SQL,
1303 &[&scope.request_id.0, &scope.datastore_id.0],
1304 )?
1305 .map(classify_custodian_approval_preflight)
1306 .transpose()
1307 .map(|inspection| inspection.unwrap_or(AccessRequestCustodianInspection::NotFound))
1308}
1309
1310fn classify_custodian_approval_preflight<RowType>(
1311 row: RowType,
1312) -> Result<AccessRequestCustodianInspection, PgMetaError>
1313where
1314 RowType: AccessRequestRow,
1315{
1316 let is_custodian = matches!(
1317 row.optional_access_request_text(2, "is_custodian")?
1318 .as_deref(),
1319 Some("t" | "true")
1320 );
1321 if !is_custodian {
1322 return Ok(AccessRequestCustodianInspection::NotCustodian);
1323 }
1324 let lifecycle =
1325 AccessRequestLifecycle::decode(&row.required_access_request_text(1, "request_status")?)?;
1326 if lifecycle != AccessRequestLifecycle::Pending {
1327 return Ok(AccessRequestCustodianInspection::NotPending);
1328 }
1329 Ok(AccessRequestCustodianInspection::Pending {
1330 requester_orcid: row.required_access_request_text(0, "requester_orcid")?,
1331 })
1332}
1333
1334fn approve_custodian_study_access_request_with_executor<E>(
1335 executor: &mut E,
1336 approval: &CustodianStudyAccessRequestApproval,
1337) -> Result<AccessRequestCustodianApprovalOutcome, PgMetaError>
1338where
1339 E: MetadataExecutor,
1340 E::Row: AccessRequestRow,
1341{
1342 let approved = executor
1343 .query_optional(
1344 "approve pending custodian Study access request",
1345 APPROVE_CUSTODIAN_STUDY_ACCESS_REQUEST_SQL,
1346 &[
1347 &approval.request_id.0,
1348 &approval.datastore_id.0,
1349 &approval.requester_role,
1350 ],
1351 )?
1352 .map(|row| decode_custodian_study_access_request(&row, approval.datastore_id))
1353 .transpose()?;
1354 Ok(match approved {
1355 Some(request) => AccessRequestCustodianApprovalOutcome::Approved(Box::new(request)),
1356 None => AccessRequestCustodianApprovalOutcome::NotPending,
1357 })
1358}
1359
1360pub(crate) fn decode_requester_study_access_request<RowType>(
1365 row: &RowType,
1366 datastore_id: AccessRequestDatastoreId,
1367) -> Result<RequesterStudyAccessRequest, PgMetaError>
1368where
1369 RowType: AccessRequestRow,
1370{
1371 Ok(RequesterStudyAccessRequest {
1372 request_id: AccessRequestId(required_uuid(row, 0, "request_id")?),
1373 datastore_id,
1374 study_id: StudyId(required_uuid(row, 1, "study_id")?),
1375 intended_use: row.required_access_request_text(2, "intended_use")?,
1376 requester_contact: row.optional_access_request_text(3, "requester_contact")?,
1377 requester_affiliation: row.optional_access_request_text(4, "requester_affiliation")?,
1378 lifecycle: AccessRequestLifecycle::decode(
1379 &row.required_access_request_text(5, "request_status")?,
1380 )?,
1381 submitted_at: required_timestamp(row, 6, "submitted_at")?,
1382 decision: StudyAccessRequestDecision {
1383 decided_at: optional_timestamp(row, 7, "decided_at")?,
1384 decided_by: None,
1385 reason: row.optional_access_request_text(8, "decision_reason")?,
1386 },
1387 })
1388}
1389
1390pub(crate) fn decode_custodian_study_access_request<RowType>(
1396 row: &RowType,
1397 datastore_id: AccessRequestDatastoreId,
1398) -> Result<CustodianStudyAccessRequest, PgMetaError>
1399where
1400 RowType: AccessRequestRow,
1401{
1402 Ok(CustodianStudyAccessRequest {
1403 request_id: AccessRequestId(required_uuid(row, 0, "request_id")?),
1404 datastore_id,
1405 study_id: StudyId(required_uuid(row, 1, "study_id")?),
1406 requester_orcid: row.required_access_request_text(2, "requester_orcid")?,
1407 intended_use: row.required_access_request_text(3, "intended_use")?,
1408 requester_contact: row.optional_access_request_text(4, "requester_contact")?,
1409 requester_affiliation: row.optional_access_request_text(5, "requester_affiliation")?,
1410 lifecycle: AccessRequestLifecycle::decode(
1411 &row.required_access_request_text(6, "request_status")?,
1412 )?,
1413 submitted_at: required_timestamp(row, 7, "submitted_at")?,
1414 decision: StudyAccessRequestDecision {
1415 decided_at: optional_timestamp(row, 8, "decided_at")?,
1416 decided_by: row.optional_access_request_text(9, "decided_by")?,
1417 reason: row.optional_access_request_text(10, "decision_reason")?,
1418 },
1419 })
1420}
1421
1422fn decode_custodian_access_request_inspection<RowType>(
1423 row: RowType,
1424) -> Result<CustodianAccessRequestInspectionRecord, PgMetaError>
1425where
1426 RowType: AccessRequestRow,
1427{
1428 Ok(CustodianAccessRequestInspectionRecord {
1429 requester_orcid: row.required_access_request_text(0, "requester_orcid")?,
1430 lifecycle: AccessRequestLifecycle::decode(
1431 &row.required_access_request_text(1, "request_status")?,
1432 )?,
1433 is_custodian: required_bool_at(&row, 2, "is_custodian")?,
1434 })
1435}
1436
1437fn required_uuid<RowType>(
1438 row: &RowType,
1439 index: usize,
1440 field: &'static str,
1441) -> Result<Uuid, PgMetaError>
1442where
1443 RowType: AccessRequestRow,
1444{
1445 let value = row.required_access_request_text(index, field)?;
1446 Uuid::parse_str(&value).map_err(|_| PgMetaError::Decode {
1447 field,
1448 value,
1449 message: "expected UUID text".to_string(),
1450 })
1451}
1452
1453fn required_bool<RowType>(row: &RowType, field: &'static str) -> Result<bool, PgMetaError>
1454where
1455 RowType: AccessRequestRow,
1456{
1457 required_bool_at(row, 0, field)
1458}
1459
1460fn required_bool_at<RowType>(
1461 row: &RowType,
1462 index: usize,
1463 field: &'static str,
1464) -> Result<bool, PgMetaError>
1465where
1466 RowType: AccessRequestRow,
1467{
1468 match row.required_access_request_text(index, field)?.as_str() {
1469 "true" => Ok(true),
1470 "false" => Ok(false),
1471 value => Err(PgMetaError::Decode {
1472 field,
1473 value: value.to_string(),
1474 message: "expected PostgreSQL boolean text".to_string(),
1475 }),
1476 }
1477}
1478
1479fn required_timestamp<RowType>(
1480 row: &RowType,
1481 index: usize,
1482 field: &'static str,
1483) -> Result<DateTime<Utc>, PgMetaError>
1484where
1485 RowType: AccessRequestRow,
1486{
1487 let value = row.required_access_request_text(index, field)?;
1488 parse_timestamp(value, field)
1489}
1490
1491fn optional_timestamp<RowType>(
1492 row: &RowType,
1493 index: usize,
1494 field: &'static str,
1495) -> Result<Option<DateTime<Utc>>, PgMetaError>
1496where
1497 RowType: AccessRequestRow,
1498{
1499 row.optional_access_request_text(index, field)?
1500 .map(|value| parse_timestamp(value, field))
1501 .transpose()
1502}
1503
1504fn parse_timestamp(value: String, field: &'static str) -> Result<DateTime<Utc>, PgMetaError> {
1505 DateTime::parse_from_rfc3339(&value)
1506 .map(|timestamp| timestamp.with_timezone(&Utc))
1507 .map_err(|_| PgMetaError::Decode {
1508 field,
1509 value,
1510 message: "expected RFC 3339 timestamp text".to_string(),
1511 })
1512}
1513
1514fn missing_required_field(field: &'static str) -> PgMetaError {
1515 PgMetaError::Decode {
1516 field,
1517 value: "<null>".to_string(),
1518 message: "required access-request field was null or missing".to_string(),
1519 }
1520}
1521
1522pub fn classify_requester_withdrawal(
1523 withdrawn: Option<RequesterStudyAccessRequest>,
1524 existing: Option<RequesterStudyAccessRequest>,
1525) -> AccessRequestWithdrawalOutcome {
1526 match (withdrawn, existing) {
1527 (Some(request), _) => AccessRequestWithdrawalOutcome::Withdrawn(request),
1528 (None, Some(_)) => AccessRequestWithdrawalOutcome::NotPending,
1529 (None, None) => AccessRequestWithdrawalOutcome::NotFound,
1530 }
1531}
1532
1533pub fn classify_custodian_inspection(
1534 request: Option<CustodianAccessRequestInspectionRecord>,
1535) -> AccessRequestCustodianInspection {
1536 match request {
1537 None => AccessRequestCustodianInspection::NotFound,
1538 Some(CustodianAccessRequestInspectionRecord {
1539 is_custodian: false,
1540 ..
1541 }) => AccessRequestCustodianInspection::NotCustodian,
1542 Some(CustodianAccessRequestInspectionRecord {
1543 requester_orcid,
1544 lifecycle: AccessRequestLifecycle::Pending,
1545 is_custodian: true,
1546 }) => AccessRequestCustodianInspection::Pending { requester_orcid },
1547 Some(CustodianAccessRequestInspectionRecord {
1548 is_custodian: true, ..
1549 }) => AccessRequestCustodianInspection::NotPending,
1550 }
1551}
1552
1553pub fn classify_custodian_decision(
1554 decided: Option<CustodianStudyAccessRequest>,
1555 inspection: AccessRequestCustodianInspection,
1556) -> AccessRequestCustodianDecisionOutcome {
1557 match decided {
1558 Some(request) => AccessRequestCustodianDecisionOutcome::Decided(Box::new(request)),
1559 None => match inspection {
1560 AccessRequestCustodianInspection::NotFound => {
1561 AccessRequestCustodianDecisionOutcome::NotFound
1562 }
1563 AccessRequestCustodianInspection::NotCustodian => {
1564 AccessRequestCustodianDecisionOutcome::NotCustodian
1565 }
1566 AccessRequestCustodianInspection::NotPending
1567 | AccessRequestCustodianInspection::Pending { .. } => {
1568 AccessRequestCustodianDecisionOutcome::NotPending
1569 }
1570 },
1571 }
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576 use super::{
1577 AccessRequestCustodianDecisionOutcome, AccessRequestCustodianInspection,
1578 AccessRequestDatastoreId, AccessRequestId, AccessRequestLifecycle, AccessRequestRow,
1579 AccessRequestSubmission, AccessRequestSubmissionOutcome, AccessRequestWithdrawalOutcome,
1580 CustodianAccessRequestInspectionRecord, CustodianStudyAccessRequestApproval,
1581 CustodianStudyAccessRequestRejection, CustodianStudyAccessRequestScope,
1582 RequesterStudyAccessRequest, approve_custodian_study_access_request_with_executor,
1583 classify_custodian_decision, classify_custodian_inspection, classify_requester_withdrawal,
1584 decode_requester_study_access_request, get_application_requester_study_access_request,
1585 inspect_custodian_approval_preflight_with_executor,
1586 list_application_requester_study_access_requests,
1587 list_custodian_study_access_requests_with_executor,
1588 list_requester_study_access_requests_with_executor,
1589 reject_custodian_study_access_request_with_executor, require_current_user_custodianship,
1590 submit_application_study_access_request, submit_study_access_request_with_executor,
1591 withdraw_application_requester_study_access_request,
1592 };
1593 use crate::{MetadataExecutor, MetadataParameters, MetadataTransaction, PgMetaError};
1594 use ahri_tre_core::{
1595 CustodianStudyAccessRequestPortError, CustodianStudyAccessRequestStatusFilter,
1596 };
1597 use bytes::BytesMut;
1598 use chrono::{TimeZone, Utc};
1599 use postgres::types::{IsNull, Type};
1600 use std::cell::Cell;
1601 use std::collections::VecDeque;
1602 use uuid::Uuid;
1603
1604 #[derive(Debug, Clone)]
1605 struct FakeRow(Vec<Option<&'static str>>);
1606
1607 impl AccessRequestRow for FakeRow {
1608 fn required_access_request_text(
1609 &self,
1610 index: usize,
1611 field: &'static str,
1612 ) -> Result<String, PgMetaError> {
1613 self.optional_access_request_text(index, field)?
1614 .ok_or_else(|| PgMetaError::Decode {
1615 field,
1616 value: "<null>".to_string(),
1617 message: "required access-request field was null or missing".to_string(),
1618 })
1619 }
1620
1621 fn optional_access_request_text(
1622 &self,
1623 index: usize,
1624 _field: &'static str,
1625 ) -> Result<Option<String>, PgMetaError> {
1626 Ok(self.0.get(index).copied().flatten().map(ToOwned::to_owned))
1627 }
1628 }
1629
1630 #[derive(Default)]
1631 struct FakeExecutor {
1632 requester_rows: VecDeque<FakeRow>,
1633 optional_rows: VecDeque<Option<FakeRow>>,
1634 many_rows: VecDeque<Vec<FakeRow>>,
1635 operations: Vec<&'static str>,
1636 parameter_counts: Vec<usize>,
1637 parameter_values: Vec<Vec<Option<String>>>,
1638 }
1639
1640 impl MetadataExecutor for FakeExecutor {
1641 type Row = FakeRow;
1642
1643 fn query_one(
1644 &mut self,
1645 operation: &'static str,
1646 _statement: &str,
1647 params: MetadataParameters<'_>,
1648 ) -> Result<Self::Row, PgMetaError> {
1649 self.operations.push(operation);
1650 self.parameter_counts.push(params.len());
1651 self.parameter_values
1652 .push(record_parameter_values(operation, params));
1653 self.requester_rows.pop_front().ok_or_else(missing_fake_row)
1654 }
1655
1656 fn query_optional(
1657 &mut self,
1658 operation: &'static str,
1659 _statement: &str,
1660 params: MetadataParameters<'_>,
1661 ) -> Result<Option<Self::Row>, PgMetaError> {
1662 self.operations.push(operation);
1663 self.parameter_counts.push(params.len());
1664 self.parameter_values
1665 .push(record_parameter_values(operation, params));
1666 Ok(if self.optional_rows.is_empty() {
1667 self.requester_rows.pop_front()
1668 } else {
1669 self.optional_rows.pop_front().flatten()
1670 })
1671 }
1672
1673 fn query_many(
1674 &mut self,
1675 operation: &'static str,
1676 _statement: &str,
1677 params: MetadataParameters<'_>,
1678 ) -> Result<Vec<Self::Row>, PgMetaError> {
1679 self.operations.push(operation);
1680 self.parameter_counts.push(params.len());
1681 self.parameter_values
1682 .push(record_parameter_values(operation, params));
1683 self.many_rows.pop_front().ok_or_else(missing_fake_row)
1684 }
1685
1686 fn execute_command(
1687 &mut self,
1688 operation: &'static str,
1689 _statement: &str,
1690 params: MetadataParameters<'_>,
1691 ) -> Result<u64, PgMetaError> {
1692 self.operations.push(operation);
1693 self.parameter_counts.push(params.len());
1694 self.parameter_values
1695 .push(record_parameter_values(operation, params));
1696 Ok(1)
1697 }
1698
1699 fn with_transaction<T>(
1700 &mut self,
1701 _operation: &'static str,
1702 _f: impl FnOnce(&mut dyn MetadataTransaction<Row = Self::Row>) -> Result<T, PgMetaError>,
1703 ) -> Result<T, PgMetaError> {
1704 Err(missing_fake_row())
1705 }
1706 }
1707
1708 #[test]
1709 fn lifecycle_decoding_accepts_all_persisted_values_and_rejects_unknown_values() {
1710 for lifecycle in [
1711 AccessRequestLifecycle::Pending,
1712 AccessRequestLifecycle::Approved,
1713 AccessRequestLifecycle::Rejected,
1714 AccessRequestLifecycle::Withdrawn,
1715 AccessRequestLifecycle::Expired,
1716 ] {
1717 assert_eq!(
1718 AccessRequestLifecycle::decode(lifecycle.as_db_str()).unwrap(),
1719 lifecycle
1720 );
1721 }
1722
1723 let error = AccessRequestLifecycle::decode("in_review").unwrap_err();
1724 assert!(matches!(
1725 error,
1726 PgMetaError::Decode {
1727 field: "request_status",
1728 ..
1729 }
1730 ));
1731 }
1732
1733 #[test]
1734 fn fake_executor_characterizes_requester_projection_and_withdrawal_outcomes() {
1735 let datastore_id = AccessRequestDatastoreId(Uuid::from_u128(1));
1736 let mut executor = FakeExecutor::default();
1737 executor
1738 .many_rows
1739 .push_back(vec![requester_row("rejected")]);
1740
1741 let request = list_requester_study_access_requests_with_executor(
1742 &mut executor,
1743 &super::RequesterStudyAccessRequestScope {
1744 datastore_id,
1745 requester_orcid: "0000-0001-2345-6789".to_string(),
1746 },
1747 )
1748 .unwrap()
1749 .pop()
1750 .unwrap();
1751 assert_eq!(request.lifecycle, AccessRequestLifecycle::Rejected);
1752 assert_eq!(request.decision.decided_by, None);
1753 assert_eq!(
1754 request.decision.reason.as_deref(),
1755 Some("outside approved purpose")
1756 );
1757 assert_eq!(
1758 request.decision.decided_at,
1759 Some(Utc.with_ymd_and_hms(2026, 7, 19, 10, 30, 0).unwrap())
1760 );
1761 assert!(matches!(
1762 classify_requester_withdrawal(Some(requester_from_row("withdrawn")), None),
1763 AccessRequestWithdrawalOutcome::Withdrawn(RequesterStudyAccessRequest {
1764 lifecycle: AccessRequestLifecycle::Withdrawn,
1765 ..
1766 })
1767 ));
1768 assert!(matches!(
1769 classify_requester_withdrawal(None, None),
1770 AccessRequestWithdrawalOutcome::NotFound
1771 ));
1772 assert!(matches!(
1773 classify_requester_withdrawal(None, Some(requester_from_row("approved"))),
1774 AccessRequestWithdrawalOutcome::NotPending
1775 ));
1776 assert_eq!(
1777 executor.operations,
1778 ["list requester Study access requests"]
1779 );
1780 assert_eq!(executor.parameter_counts, [2]);
1781 assert_eq!(
1782 executor.parameter_values,
1783 [vec![
1784 Some("00000000-0000-0000-0000-000000000001".to_string()),
1785 Some("0000-0001-2345-6789".to_string()),
1786 ]]
1787 );
1788 }
1789
1790 #[test]
1791 fn application_requester_port_adapter_preserves_scoping_and_lifecycle_outcomes() {
1792 let scope = ahri_tre_core::RequesterStudyAccessRequests {
1793 datastore_id: Uuid::from_u128(1),
1794 requester_orcid: "0000-0001-2345-6789".to_string(),
1795 };
1796
1797 let mut list_executor = FakeExecutor::default();
1798 list_executor
1799 .many_rows
1800 .push_back(vec![requester_row("approved")]);
1801 let listed =
1802 list_application_requester_study_access_requests(&mut list_executor, &scope).unwrap();
1803 assert_eq!(listed.len(), 1);
1804 assert_eq!(
1805 listed[0].lifecycle,
1806 ahri_tre_core::StudyAccessRequestLifecycle::Approved
1807 );
1808 assert_eq!(list_executor.parameter_counts, [2]);
1809
1810 let mut get_executor = FakeExecutor::default();
1811 get_executor
1812 .optional_rows
1813 .push_back(Some(requester_row("pending")));
1814 let found = get_application_requester_study_access_request(
1815 &mut get_executor,
1816 &scope,
1817 Uuid::from_u128(3),
1818 )
1819 .unwrap()
1820 .unwrap();
1821 assert_eq!(
1822 found.lifecycle,
1823 ahri_tre_core::StudyAccessRequestLifecycle::Pending
1824 );
1825 assert_eq!(get_executor.parameter_counts, [3]);
1826
1827 let mut withdraw_executor = FakeExecutor::default();
1828 withdraw_executor
1829 .optional_rows
1830 .push_back(Some(requester_row("withdrawn")));
1831 assert!(matches!(
1832 withdraw_application_requester_study_access_request(
1833 &mut withdraw_executor,
1834 &scope,
1835 Uuid::from_u128(3),
1836 )
1837 .unwrap(),
1838 ahri_tre_core::RequesterStudyAccessRequestWithdrawalOutcome::Withdrawn(
1839 ahri_tre_core::RequesterStudyAccessRequest {
1840 lifecycle: ahri_tre_core::StudyAccessRequestLifecycle::Withdrawn,
1841 ..
1842 }
1843 )
1844 ));
1845 assert_eq!(withdraw_executor.parameter_counts, [3]);
1846 }
1847
1848 #[test]
1849 fn fake_executor_scopes_requester_detail_and_atomic_withdrawal() {
1850 let datastore_id = AccessRequestDatastoreId(Uuid::from_u128(1));
1851 let scope = super::RequesterStudyAccessRequestScope {
1852 datastore_id,
1853 requester_orcid: "0000-0001-2345-6789".to_string(),
1854 };
1855 let request_id = AccessRequestId(Uuid::from_u128(3));
1856 let mut executor = FakeExecutor::default();
1857 executor.optional_rows.extend([
1858 Some(requester_row("rejected")),
1859 Some(requester_row("withdrawn")),
1860 ]);
1861
1862 let detail = super::get_requester_study_access_request_with_executor(
1863 &mut executor,
1864 &scope,
1865 request_id,
1866 )
1867 .unwrap()
1868 .unwrap();
1869 assert_eq!(detail.lifecycle, AccessRequestLifecycle::Rejected);
1870
1871 assert!(matches!(
1872 super::withdraw_requester_study_access_request_with_executor(
1873 &mut executor,
1874 &scope,
1875 request_id,
1876 )
1877 .unwrap(),
1878 AccessRequestWithdrawalOutcome::Withdrawn(RequesterStudyAccessRequest {
1879 lifecycle: AccessRequestLifecycle::Withdrawn,
1880 ..
1881 })
1882 ));
1883 assert_eq!(
1884 executor.operations,
1885 [
1886 "get requester Study access request",
1887 "withdraw pending requester Study access request",
1888 ]
1889 );
1890 assert_eq!(executor.parameter_counts, [3, 3]);
1891 assert_eq!(
1892 executor.parameter_values,
1893 [
1894 vec![
1895 Some("00000000-0000-0000-0000-000000000003".to_string()),
1896 Some("00000000-0000-0000-0000-000000000001".to_string()),
1897 Some("0000-0001-2345-6789".to_string()),
1898 ],
1899 vec![
1900 Some("00000000-0000-0000-0000-000000000003".to_string()),
1901 Some("00000000-0000-0000-0000-000000000001".to_string()),
1902 Some("0000-0001-2345-6789".to_string()),
1903 ],
1904 ]
1905 );
1906 }
1907
1908 #[test]
1909 fn fake_executor_distinguishes_absent_and_non_pending_withdrawals() {
1910 let datastore_id = AccessRequestDatastoreId(Uuid::from_u128(1));
1911 let scope = super::RequesterStudyAccessRequestScope {
1912 datastore_id,
1913 requester_orcid: "0000-0001-2345-6789".to_string(),
1914 };
1915 let request_id = AccessRequestId(Uuid::from_u128(3));
1916
1917 let mut absent = FakeExecutor::default();
1918 absent.optional_rows.extend([None, None]);
1919 assert_eq!(
1920 super::withdraw_requester_study_access_request_with_executor(
1921 &mut absent,
1922 &scope,
1923 request_id,
1924 )
1925 .unwrap(),
1926 AccessRequestWithdrawalOutcome::NotFound
1927 );
1928
1929 let mut non_pending = FakeExecutor::default();
1930 non_pending
1931 .optional_rows
1932 .extend([None, Some(requester_row("approved"))]);
1933 assert_eq!(
1934 super::withdraw_requester_study_access_request_with_executor(
1935 &mut non_pending,
1936 &scope,
1937 request_id,
1938 )
1939 .unwrap(),
1940 AccessRequestWithdrawalOutcome::NotPending
1941 );
1942 assert_eq!(
1943 non_pending.operations,
1944 [
1945 "withdraw pending requester Study access request",
1946 "get requester Study access request",
1947 ]
1948 );
1949 }
1950
1951 #[test]
1952 fn fake_executor_submits_only_after_scoped_study_and_duplicate_checks() {
1953 let mut executor = FakeExecutor::default();
1954 executor.requester_rows.extend([
1955 FakeRow(vec![Some("true")]),
1956 FakeRow(vec![Some("false")]),
1957 FakeRow(vec![Some("2026-07-19T10:00:00.000000Z")]),
1958 ]);
1959
1960 let outcome = submit_with_fake_request_id(&mut executor, &submission()).unwrap();
1961
1962 assert_eq!(
1963 outcome,
1964 AccessRequestSubmissionOutcome::Submitted {
1965 request_id: AccessRequestId(Uuid::from_u128(3)),
1966 submitted_at: Utc.with_ymd_and_hms(2026, 7, 19, 10, 0, 0).unwrap(),
1967 }
1968 );
1969 assert_eq!(
1970 executor.operations,
1971 [
1972 "check selected Study for access request submission",
1973 "check pending Study access request duplicate",
1974 "submit pending Study access request",
1975 ]
1976 );
1977 assert_eq!(executor.parameter_counts, [1, 3, 8]);
1978 assert_eq!(
1979 executor.parameter_values,
1980 [
1981 vec![Some("00000000-0000-0000-0000-000000000002".to_string())],
1982 vec![
1983 Some("00000000-0000-0000-0000-000000000001".to_string()),
1984 Some("00000000-0000-0000-0000-000000000002".to_string()),
1985 Some("0000-0001-2345-6789".to_string()),
1986 ],
1987 vec![
1988 Some("00000000-0000-0000-0000-000000000003".to_string()),
1989 Some("00000000-0000-0000-0000-000000000001".to_string()),
1990 Some("00000000-0000-0000-0000-000000000002".to_string()),
1991 Some("0000-0001-2345-6789".to_string()),
1992 Some("orcid-subject".to_string()),
1993 Some("analysis of the Study data".to_string()),
1994 Some("requester@example.test".to_string()),
1995 Some("AHRI".to_string()),
1996 ],
1997 ]
1998 );
1999 }
2000
2001 #[test]
2002 fn application_submission_port_adapter_preserves_metadata_submission_semantics() {
2003 let mut executor = FakeExecutor::default();
2004 executor.requester_rows.extend([
2005 FakeRow(vec![Some("true")]),
2006 FakeRow(vec![Some("false")]),
2007 FakeRow(vec![Some("2026-07-19T10:00:00.000000Z")]),
2008 ]);
2009 let submission = ahri_tre_core::SubmitStudyAccessRequest {
2010 datastore_id: Uuid::from_u128(1),
2011 study_id: ahri_tre_types::StudyId(Uuid::from_u128(2)),
2012 requester_orcid: "0000-0001-2345-6789".to_string(),
2013 requester_subject: "orcid-subject".to_string(),
2014 intended_use: "analysis of the Study data".to_string(),
2015 contact: Some("requester@example.test".to_string()),
2016 affiliation: Some("AHRI".to_string()),
2017 };
2018
2019 let outcome = submit_application_study_access_request(&mut executor, &submission).unwrap();
2020
2021 assert!(matches!(
2022 outcome,
2023 ahri_tre_core::StudyAccessRequestSubmissionOutcome::Submitted(record)
2024 if record.submitted_at == Utc.with_ymd_and_hms(2026, 7, 19, 10, 0, 0).unwrap()
2025 ));
2026 assert_eq!(
2027 executor.operations,
2028 [
2029 "check selected Study for access request submission",
2030 "check pending Study access request duplicate",
2031 "submit pending Study access request",
2032 ]
2033 );
2034 }
2035
2036 #[test]
2037 fn fake_executor_returns_semantic_outcomes_before_persistence() {
2038 let mut missing_study_executor = FakeExecutor::default();
2039 missing_study_executor
2040 .requester_rows
2041 .push_back(FakeRow(vec![Some("false")]));
2042 assert_eq!(
2043 submit_with_fake_request_id(&mut missing_study_executor, &submission()).unwrap(),
2044 AccessRequestSubmissionOutcome::StudyNotFound
2045 );
2046 assert_eq!(
2047 missing_study_executor.operations,
2048 ["check selected Study for access request submission"]
2049 );
2050
2051 let mut duplicate_executor = FakeExecutor::default();
2052 duplicate_executor
2053 .requester_rows
2054 .extend([FakeRow(vec![Some("true")]), FakeRow(vec![Some("true")])]);
2055 assert_eq!(
2056 submit_with_fake_request_id(&mut duplicate_executor, &submission()).unwrap(),
2057 AccessRequestSubmissionOutcome::DuplicatePendingRequest
2058 );
2059 assert_eq!(
2060 duplicate_executor.operations,
2061 [
2062 "check selected Study for access request submission",
2063 "check pending Study access request duplicate",
2064 ]
2065 );
2066 }
2067
2068 #[test]
2069 fn request_identity_is_generated_only_after_submission_prechecks() {
2070 let mut executor = FakeExecutor::default();
2071 executor
2072 .requester_rows
2073 .push_back(FakeRow(vec![Some("false")]));
2074 let generated = Cell::new(false);
2075
2076 assert_eq!(
2077 submit_study_access_request_with_executor(&mut executor, &submission(), || {
2078 generated.set(true);
2079 AccessRequestId(Uuid::from_u128(3))
2080 })
2081 .unwrap(),
2082 AccessRequestSubmissionOutcome::StudyNotFound
2083 );
2084 assert!(!generated.get());
2085 }
2086
2087 #[test]
2088 fn fake_executor_classifies_a_concurrent_duplicate_insert_as_semantic_duplicate() {
2089 let mut executor = FakeExecutor::default();
2090 executor
2091 .requester_rows
2092 .extend([FakeRow(vec![Some("true")]), FakeRow(vec![Some("false")])]);
2093
2094 assert_eq!(
2095 submit_with_fake_request_id(&mut executor, &submission()).unwrap(),
2096 AccessRequestSubmissionOutcome::DuplicatePendingRequest
2097 );
2098 assert_eq!(
2099 executor.operations,
2100 [
2101 "check selected Study for access request submission",
2102 "check pending Study access request duplicate",
2103 "submit pending Study access request",
2104 ]
2105 );
2106 }
2107
2108 #[test]
2109 fn fake_executor_characterizes_custodian_projection_and_semantic_failures() {
2110 let datastore_id = AccessRequestDatastoreId(Uuid::from_u128(1));
2111 let mut executor = FakeExecutor::default();
2112 executor
2113 .many_rows
2114 .push_back(vec![custodian_row_with_lifecycle("approved")]);
2115
2116 let request = list_custodian_study_access_requests_with_executor(
2117 &mut executor,
2118 datastore_id,
2119 CustodianStudyAccessRequestStatusFilter::Approved,
2120 )
2121 .unwrap()
2122 .pop()
2123 .unwrap();
2124 assert_eq!(request.requester_orcid, "0000-0001-2345-6789");
2125 assert_eq!(request.lifecycle, AccessRequestLifecycle::Approved);
2126 assert_eq!(request.decision.decided_at, None);
2127 assert_eq!(request.decision.decided_by, None);
2128 assert_eq!(request.decision.reason, None);
2129 assert_eq!(executor.parameter_counts, [2]);
2130 assert_eq!(
2131 executor.parameter_values,
2132 [vec![
2133 Some("00000000-0000-0000-0000-000000000001".to_string()),
2134 Some("approved".to_string()),
2135 ]]
2136 );
2137
2138 assert_eq!(
2139 classify_custodian_inspection(None),
2140 AccessRequestCustodianInspection::NotFound
2141 );
2142 assert_eq!(
2143 classify_custodian_inspection(Some(CustodianAccessRequestInspectionRecord {
2144 requester_orcid: "0000-0001-2345-6789".to_string(),
2145 lifecycle: AccessRequestLifecycle::Pending,
2146 is_custodian: false,
2147 })),
2148 AccessRequestCustodianInspection::NotCustodian
2149 );
2150 assert_eq!(
2151 classify_custodian_inspection(Some(CustodianAccessRequestInspectionRecord {
2152 requester_orcid: "0000-0001-2345-6789".to_string(),
2153 lifecycle: AccessRequestLifecycle::Approved,
2154 is_custodian: true,
2155 })),
2156 AccessRequestCustodianInspection::NotPending
2157 );
2158 assert_eq!(
2159 classify_custodian_inspection(Some(CustodianAccessRequestInspectionRecord {
2160 requester_orcid: "0000-0001-2345-6789".to_string(),
2161 lifecycle: AccessRequestLifecycle::Pending,
2162 is_custodian: true,
2163 })),
2164 AccessRequestCustodianInspection::Pending {
2165 requester_orcid: "0000-0001-2345-6789".to_string(),
2166 }
2167 );
2168 let decision =
2169 classify_custodian_decision(Some(request), AccessRequestCustodianInspection::NotFound);
2170 assert!(matches!(
2171 decision,
2172 AccessRequestCustodianDecisionOutcome::Decided(ref request)
2173 if request.lifecycle == AccessRequestLifecycle::Approved
2174 ));
2175 assert_eq!(
2176 classify_custodian_decision(None, AccessRequestCustodianInspection::NotFound),
2177 AccessRequestCustodianDecisionOutcome::NotFound
2178 );
2179 assert_eq!(
2180 classify_custodian_decision(None, AccessRequestCustodianInspection::NotCustodian),
2181 AccessRequestCustodianDecisionOutcome::NotCustodian
2182 );
2183 assert_eq!(
2184 classify_custodian_decision(None, AccessRequestCustodianInspection::NotPending),
2185 AccessRequestCustodianDecisionOutcome::NotPending
2186 );
2187 assert!(matches!(
2188 AccessRequestSubmissionOutcome::DuplicatePendingRequest,
2189 AccessRequestSubmissionOutcome::DuplicatePendingRequest
2190 ));
2191 assert_eq!(
2192 executor.operations,
2193 ["list custodian Study access requests by status"]
2194 );
2195 }
2196
2197 #[test]
2198 fn custodian_queue_authorization_distinguishes_a_custodian_from_an_authenticated_user() {
2199 let mut custodian = FakeExecutor::default();
2200 custodian
2201 .requester_rows
2202 .push_back(FakeRow(vec![Some("true")]));
2203 assert_eq!(require_current_user_custodianship(&mut custodian), Ok(()));
2204 assert_eq!(
2205 custodian.operations,
2206 ["authorize custodian Study access-request queue"]
2207 );
2208
2209 let mut non_custodian = FakeExecutor::default();
2210 non_custodian
2211 .requester_rows
2212 .push_back(FakeRow(vec![Some("false")]));
2213 assert_eq!(
2214 require_current_user_custodianship(&mut non_custodian),
2215 Err(CustodianStudyAccessRequestPortError::CustodianshipRequired)
2216 );
2217 }
2218
2219 #[test]
2220 fn fake_executor_rejects_custodian_requests_with_typed_outcomes() {
2221 let rejection = CustodianStudyAccessRequestRejection {
2222 datastore_id: AccessRequestDatastoreId(Uuid::from_u128(1)),
2223 request_id: AccessRequestId(Uuid::from_u128(3)),
2224 reason: Some("outside approved purpose".to_string()),
2225 };
2226 let mut decided = FakeExecutor::default();
2227 decided
2228 .optional_rows
2229 .push_back(Some(custodian_row_with_lifecycle("rejected")));
2230 assert!(matches!(
2231 reject_custodian_study_access_request_with_executor(&mut decided, &rejection)
2232 .unwrap(),
2233 AccessRequestCustodianDecisionOutcome::Decided(request)
2234 if request.lifecycle == AccessRequestLifecycle::Rejected
2235 ));
2236 assert_eq!(
2237 decided.operations,
2238 ["reject pending custodian Study access request"]
2239 );
2240 assert_eq!(decided.parameter_counts, [3]);
2241 assert_eq!(
2242 decided.parameter_values,
2243 [vec![
2244 Some("00000000-0000-0000-0000-000000000003".to_string()),
2245 Some("00000000-0000-0000-0000-000000000001".to_string()),
2246 Some("outside approved purpose".to_string()),
2247 ]]
2248 );
2249
2250 let mut absent = FakeExecutor::default();
2251 absent.optional_rows.extend([None, None]);
2252 assert_eq!(
2253 reject_custodian_study_access_request_with_executor(&mut absent, &rejection).unwrap(),
2254 AccessRequestCustodianDecisionOutcome::NotFound
2255 );
2256
2257 let mut non_custodian = FakeExecutor::default();
2258 non_custodian
2259 .optional_rows
2260 .extend([None, Some(custodian_inspection_row("pending", "false"))]);
2261 assert_eq!(
2262 reject_custodian_study_access_request_with_executor(&mut non_custodian, &rejection)
2263 .unwrap(),
2264 AccessRequestCustodianDecisionOutcome::NotCustodian
2265 );
2266
2267 let mut non_pending = FakeExecutor::default();
2268 non_pending
2269 .optional_rows
2270 .extend([None, Some(custodian_inspection_row("approved", "true"))]);
2271 assert_eq!(
2272 reject_custodian_study_access_request_with_executor(&mut non_pending, &rejection)
2273 .unwrap(),
2274 AccessRequestCustodianDecisionOutcome::NotPending
2275 );
2276 }
2277
2278 #[test]
2279 fn fake_executor_inspects_custodian_approval_preconditions() {
2280 let scope = CustodianStudyAccessRequestScope {
2281 datastore_id: AccessRequestDatastoreId(Uuid::from_u128(1)),
2282 request_id: AccessRequestId(Uuid::from_u128(3)),
2283 };
2284
2285 let mut absent = FakeExecutor::default();
2286 absent.optional_rows.push_back(None);
2287 assert_eq!(
2288 inspect_custodian_approval_preflight_with_executor(&mut absent, &scope).unwrap(),
2289 AccessRequestCustodianInspection::NotFound
2290 );
2291
2292 let mut non_custodian = FakeExecutor::default();
2293 non_custodian
2294 .optional_rows
2295 .push_back(Some(custodian_inspection_row("pending", "false")));
2296 assert_eq!(
2297 inspect_custodian_approval_preflight_with_executor(&mut non_custodian, &scope).unwrap(),
2298 AccessRequestCustodianInspection::NotCustodian
2299 );
2300
2301 let mut non_pending = FakeExecutor::default();
2302 non_pending
2303 .optional_rows
2304 .push_back(Some(custodian_inspection_row("approved", "true")));
2305 assert_eq!(
2306 inspect_custodian_approval_preflight_with_executor(&mut non_pending, &scope).unwrap(),
2307 AccessRequestCustodianInspection::NotPending
2308 );
2309
2310 let mut pending = FakeExecutor::default();
2311 pending
2312 .optional_rows
2313 .push_back(Some(custodian_inspection_row("pending", "true")));
2314 assert_eq!(
2315 inspect_custodian_approval_preflight_with_executor(&mut pending, &scope).unwrap(),
2316 AccessRequestCustodianInspection::Pending {
2317 requester_orcid: "0000-0001-2345-6789".to_string(),
2318 }
2319 );
2320 assert_eq!(
2321 pending.operations,
2322 ["inspect custodian Study access request"]
2323 );
2324 assert_eq!(pending.parameter_counts, [2]);
2325 assert_eq!(
2326 pending.parameter_values,
2327 [vec![
2328 Some("00000000-0000-0000-0000-000000000003".to_string()),
2329 Some("00000000-0000-0000-0000-000000000001".to_string()),
2330 ]]
2331 );
2332
2333 let mut unknown_lifecycle = FakeExecutor::default();
2334 unknown_lifecycle
2335 .optional_rows
2336 .push_back(Some(custodian_inspection_row("in_review", "true")));
2337 assert!(matches!(
2338 inspect_custodian_approval_preflight_with_executor(&mut unknown_lifecycle, &scope),
2339 Err(PgMetaError::Decode {
2340 field: "request_status",
2341 ..
2342 })
2343 ));
2344 }
2345
2346 #[test]
2347 fn fake_executor_approves_with_the_preflighted_requester_role() {
2348 let approval = CustodianStudyAccessRequestApproval {
2349 datastore_id: AccessRequestDatastoreId(Uuid::from_u128(1)),
2350 request_id: AccessRequestId(Uuid::from_u128(3)),
2351 requester_role: "tre_orcid_0000_0001_2345_6789".to_string(),
2352 };
2353 let mut executor = FakeExecutor::default();
2354 executor
2355 .optional_rows
2356 .push_back(Some(custodian_row_with_lifecycle("approved")));
2357
2358 assert!(matches!(
2359 approve_custodian_study_access_request_with_executor(&mut executor, &approval)
2360 .unwrap(),
2361 super::AccessRequestCustodianApprovalOutcome::Approved(request)
2362 if request.lifecycle == AccessRequestLifecycle::Approved
2363 ));
2364 assert_eq!(
2365 executor.operations,
2366 ["approve pending custodian Study access request"]
2367 );
2368 assert_eq!(executor.parameter_counts, [3]);
2369 assert_eq!(
2370 executor.parameter_values,
2371 [vec![
2372 Some("00000000-0000-0000-0000-000000000003".to_string()),
2373 Some("00000000-0000-0000-0000-000000000001".to_string()),
2374 Some("tre_orcid_0000_0001_2345_6789".to_string()),
2375 ]]
2376 );
2377
2378 let mut no_longer_pending = FakeExecutor::default();
2379 no_longer_pending.optional_rows.push_back(None);
2380 assert_eq!(
2381 approve_custodian_study_access_request_with_executor(&mut no_longer_pending, &approval)
2382 .unwrap(),
2383 super::AccessRequestCustodianApprovalOutcome::NotPending
2384 );
2385 }
2386
2387 #[test]
2388 fn invalid_persisted_row_values_are_adapter_decode_failures() {
2389 let error = decode_requester_study_access_request(
2390 &FakeRow(vec![
2391 Some("not-a-uuid"),
2392 Some("00000000-0000-0000-0000-000000000002"),
2393 Some("analysis"),
2394 None,
2395 None,
2396 Some("pending"),
2397 Some("2026-07-19T10:30:00.000000Z"),
2398 None,
2399 None,
2400 ]),
2401 AccessRequestDatastoreId(Uuid::from_u128(1)),
2402 )
2403 .unwrap_err();
2404 assert!(matches!(
2405 error,
2406 PgMetaError::Decode {
2407 field: "request_id",
2408 ..
2409 }
2410 ));
2411 }
2412
2413 fn requester_row(lifecycle: &'static str) -> FakeRow {
2414 FakeRow(vec![
2415 Some("00000000-0000-0000-0000-000000000003"),
2416 Some("00000000-0000-0000-0000-000000000002"),
2417 Some("analysis of the Study data"),
2418 Some("requester@example.test"),
2419 Some("AHRI"),
2420 Some(lifecycle),
2421 Some("2026-07-19T10:00:00.000000Z"),
2422 Some("2026-07-19T10:30:00.000000Z"),
2423 Some("outside approved purpose"),
2424 ])
2425 }
2426
2427 fn submission() -> AccessRequestSubmission {
2428 AccessRequestSubmission {
2429 datastore_id: AccessRequestDatastoreId(Uuid::from_u128(1)),
2430 study_id: ahri_tre_types::StudyId(Uuid::from_u128(2)),
2431 requester_orcid: "0000-0001-2345-6789".to_string(),
2432 requester_subject: "orcid-subject".to_string(),
2433 intended_use: "analysis of the Study data".to_string(),
2434 requester_contact: Some("requester@example.test".to_string()),
2435 requester_affiliation: Some("AHRI".to_string()),
2436 }
2437 }
2438
2439 fn submit_with_fake_request_id(
2440 executor: &mut FakeExecutor,
2441 submission: &AccessRequestSubmission,
2442 ) -> Result<AccessRequestSubmissionOutcome, PgMetaError> {
2443 submit_study_access_request_with_executor(executor, submission, || {
2444 AccessRequestId(Uuid::from_u128(3))
2445 })
2446 }
2447
2448 fn custodian_row_with_lifecycle(lifecycle: &'static str) -> FakeRow {
2449 FakeRow(vec![
2450 Some("00000000-0000-0000-0000-000000000003"),
2451 Some("00000000-0000-0000-0000-000000000002"),
2452 Some("0000-0001-2345-6789"),
2453 Some("analysis of the Study data"),
2454 None,
2455 None,
2456 Some(lifecycle),
2457 Some("2026-07-19T10:00:00.000000Z"),
2458 None,
2459 None,
2460 None,
2461 ])
2462 }
2463
2464 fn custodian_inspection_row(lifecycle: &'static str, is_custodian: &'static str) -> FakeRow {
2465 FakeRow(vec![
2466 Some("0000-0001-2345-6789"),
2467 Some(lifecycle),
2468 Some(is_custodian),
2469 ])
2470 }
2471
2472 fn requester_from_row(lifecycle: &'static str) -> RequesterStudyAccessRequest {
2473 decode_requester_study_access_request(
2474 &requester_row(lifecycle),
2475 AccessRequestDatastoreId(Uuid::from_u128(1)),
2476 )
2477 .unwrap()
2478 }
2479
2480 fn missing_fake_row() -> PgMetaError {
2481 PgMetaError::Decode {
2482 field: "fake access-request row",
2483 value: "<missing>".to_string(),
2484 message: "fake executor was not configured with a row".to_string(),
2485 }
2486 }
2487
2488 fn record_parameter_values(
2489 operation: &str,
2490 params: MetadataParameters<'_>,
2491 ) -> Vec<Option<String>> {
2492 let parameter_types = match operation {
2493 "check selected Study for access request submission" => &[Type::UUID][..],
2494 "check pending Study access request duplicate" => {
2495 &[Type::UUID, Type::UUID, Type::TEXT][..]
2496 }
2497 "list requester Study access requests" => &[Type::UUID, Type::TEXT][..],
2498 "list custodian Study access requests by status" => &[Type::UUID, Type::TEXT][..],
2499 "submit pending Study access request" => &[
2500 Type::UUID,
2501 Type::UUID,
2502 Type::UUID,
2503 Type::TEXT,
2504 Type::TEXT,
2505 Type::TEXT,
2506 Type::TEXT,
2507 Type::TEXT,
2508 ][..],
2509 "get requester Study access request"
2510 | "withdraw pending requester Study access request" => {
2511 &[Type::UUID, Type::UUID, Type::TEXT][..]
2512 }
2513 "reject pending custodian Study access request" => {
2514 &[Type::UUID, Type::UUID, Type::TEXT][..]
2515 }
2516 "inspect custodian Study access request" => &[Type::UUID, Type::UUID][..],
2517 "approve pending custodian Study access request" => {
2518 &[Type::UUID, Type::UUID, Type::TEXT][..]
2519 }
2520 _ => return Vec::new(),
2521 };
2522 assert_eq!(params.len(), parameter_types.len());
2523
2524 params
2525 .iter()
2526 .zip(parameter_types)
2527 .map(|(parameter, parameter_type)| {
2528 let mut bytes = BytesMut::new();
2529 match parameter
2530 .to_sql_checked(parameter_type, &mut bytes)
2531 .unwrap()
2532 {
2533 IsNull::Yes => None,
2534 IsNull::No if *parameter_type == Type::UUID => {
2535 Some(Uuid::from_slice(&bytes).unwrap().to_string())
2536 }
2537 IsNull::No => Some(String::from_utf8(bytes.to_vec()).unwrap()),
2538 }
2539 })
2540 .collect()
2541 }
2542}