Skip to main content

ahri_tre_pgmeta/
access_requests.rs

1//! Typed Study access-request persistence boundary.
2//!
3//! This module owns the models shared by direct PostgreSQL and OAuth/libpq
4//! access-request operations. Callers use its records and semantic outcomes;
5//! they do not receive PostgreSQL rows, SQLSTATEs, or protocol DTOs.
6//!
7//! Query operations are introduced incrementally. They must select access
8//! request fields as text using the projection order documented by the decode
9//! functions below, so one query definition can be run by both executor
10//! implementations.
11
12use 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/// Identity of an access-request row.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct AccessRequestId(pub Uuid);
44
45/// Selected datastore identity carried by an access-request operation.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub struct AccessRequestDatastoreId(pub Uuid);
48
49/// Persisted lifecycle of a Study access request.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum AccessRequestLifecycle {
52    Pending,
53    Approved,
54    Rejected,
55    Withdrawn,
56    Expired,
57}
58
59impl AccessRequestLifecycle {
60    /// Decodes the lifecycle text persisted by PostgreSQL.
61    ///
62    /// # Errors
63    ///
64    /// Returns a decode error for a value outside the database lifecycle.
65    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    /// Returns the lifecycle value stored in PostgreSQL.
81    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/// Optional decision metadata stored after a request is decided.
93#[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/// Input for submitting a Study access request.
101#[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/// Scope used by requester-owned operations.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct RequesterStudyAccessRequestScope {
115    pub datastore_id: AccessRequestDatastoreId,
116    pub requester_orcid: String,
117}
118
119/// Requester-visible projection of an access request.
120#[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/// Custodian-visible projection of an access request.
134#[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/// Input for a custodian's rejection of a Study access request.
149#[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/// Scope used by a custodian inspection of a Study access request.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct CustodianStudyAccessRequestScope {
159    pub datastore_id: AccessRequestDatastoreId,
160    pub request_id: AccessRequestId,
161}
162
163/// Input for a custodian's governed approval of a Study access request.
164///
165/// `requester_role` is the datastore workflow role provisioned by the caller
166/// after a successful pending-request inspection.
167#[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/// Semantic result of submitting a request.
175#[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/// Semantic result of a requester withdrawal attempt.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum AccessRequestWithdrawalOutcome {
188    Withdrawn(RequesterStudyAccessRequest),
189    NotFound,
190    NotPending,
191}
192
193/// Semantic result of inspecting an access request as a custodian.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum AccessRequestCustodianInspection {
196    NotFound,
197    NotCustodian,
198    NotPending,
199    Pending { requester_orcid: String },
200}
201
202/// Persisted facts needed to classify a custodian inspection.
203#[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/// Semantic result of a custodian approval or rejection attempt.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum AccessRequestCustodianDecisionOutcome {
213    Decided(Box<CustodianStudyAccessRequest>),
214    NotFound,
215    NotCustodian,
216    NotPending,
217}
218
219/// Semantic result of a governed custodian approval attempt.
220#[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
240/// Typed Study access-request operations supported by metadata connections.
241///
242/// Both direct PostgreSQL and OAuth/libpq connections use the same query
243/// definitions and return the same adapter-owned records.
244pub trait StudyAccessRequestMetadataExecutor {
245    /// Submits a pending access request after checking the selected Study and
246    /// an existing pending request for the same requester.
247    fn submit_study_access_request(
248        &mut self,
249        submission: &AccessRequestSubmission,
250    ) -> Result<AccessRequestSubmissionOutcome, PgMetaError>;
251
252    /// Lists access requests owned by a requester in the selected datastore.
253    fn list_requester_study_access_requests(
254        &mut self,
255        scope: &RequesterStudyAccessRequestScope,
256    ) -> Result<Vec<RequesterStudyAccessRequest>, PgMetaError>;
257
258    /// Finds an access request owned by a requester in the selected datastore.
259    fn get_requester_study_access_request(
260        &mut self,
261        scope: &RequesterStudyAccessRequestScope,
262        request_id: AccessRequestId,
263    ) -> Result<Option<RequesterStudyAccessRequest>, PgMetaError>;
264
265    /// Atomically withdraws a pending access request owned by a requester in
266    /// the selected datastore.
267    fn withdraw_requester_study_access_request(
268        &mut self,
269        scope: &RequesterStudyAccessRequestScope,
270        request_id: AccessRequestId,
271    ) -> Result<AccessRequestWithdrawalOutcome, PgMetaError>;
272
273    /// Lists access requests in one supported status visible to the current
274    /// custodian actor.
275    fn list_custodian_study_access_requests(
276        &mut self,
277        datastore_id: AccessRequestDatastoreId,
278        status: CustodianStudyAccessRequestStatusFilter,
279    ) -> Result<Vec<CustodianStudyAccessRequest>, PgMetaError>;
280
281    /// Rejects a pending request when the current database actor custodians
282    /// its Study.
283    fn reject_custodian_study_access_request(
284        &mut self,
285        rejection: &CustodianStudyAccessRequestRejection,
286    ) -> Result<AccessRequestCustodianDecisionOutcome, PgMetaError>;
287
288    /// Inspects a request before a caller provisions workflow entry for its
289    /// requester and attempts a governed approval.
290    fn inspect_custodian_study_access_request(
291        &mut self,
292        scope: &CustodianStudyAccessRequestScope,
293    ) -> Result<AccessRequestCustodianInspection, PgMetaError>;
294
295    /// Grants the preflighted requester access to the request's Study and
296    /// records an audited approval in one governed database statement.
297    fn approve_custodian_study_access_request(
298        &mut self,
299        approval: &CustodianStudyAccessRequestApproval,
300    ) -> Result<AccessRequestCustodianApprovalOutcome, PgMetaError>;
301}
302
303/// Application-port adapter over an already-authenticated OAuth/libpq
304/// metadata connection. Connection establishment remains outside pgmeta.
305pub 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
370/// Application-port adapter borrowing an OAuth connection already retained by
371/// a live Trusted-runtime Session.
372pub 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
1031/// Text-based access-request row shared by direct and OAuth/libpq executors.
1032///
1033/// Access-request queries select UUID and timestamp values as text so this
1034/// trait has the same contract for `postgres::Row` and `LibpqOAuthRow`.
1035pub(crate) trait AccessRequestRow {
1036    /// Reads a required text field at `index`.
1037    fn required_access_request_text(
1038        &self,
1039        index: usize,
1040        field: &'static str,
1041    ) -> Result<String, PgMetaError>;
1042
1043    /// Reads an optional text field at `index`.
1044    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
1360/// Decodes the requester projection selected by access-request queries.
1361///
1362/// The required projection order is request ID, Study ID, intended use,
1363/// contact, affiliation, lifecycle, submitted-at, decided-at, and reason.
1364pub(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
1390/// Decodes the custodian projection selected by access-request queries.
1391///
1392/// The required projection order is request ID, Study ID, requester ORCID,
1393/// intended use, contact, affiliation, lifecycle, submitted-at, decided-at,
1394/// decided-by, and reason.
1395pub(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}