cedros-login-server 0.0.43

Authentication server for cedros-login with email/password, Google OAuth, and Solana wallet sign-in
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
//! Accreditation (accredited investor) verification handlers
//!
//! GET  /accreditation/status                          - Current status for authenticated user
//! POST /accreditation/submit                          - Submit a verification request
//! POST /accreditation/upload                          - Upload supporting document
//! GET  /accreditation/submissions                     - List user's own submissions
//! GET  /admin/accreditation/pending                   - Admin: list pending submissions
//! GET  /admin/users/{user_id}/accreditation           - Admin: full record for a user
//! GET  /admin/accreditation/{submission_id}           - Admin: full submission detail
//! POST /admin/accreditation/{submission_id}/review    - Admin: approve or reject
//! POST /admin/users/{user_id}/accreditation/override  - Admin: manual status override

use axum::{
    extract::{Multipart, Path, Query, State},
    http::HeaderMap,
    Json,
};
use chrono::{DateTime, Utc};
use std::sync::Arc;
use uuid::Uuid;

use crate::callback::AuthCallback;
use crate::errors::AppError;
use crate::models::ListUsersQueryParams;
use crate::repositories::{
    pagination::cap_limit, pagination::cap_offset, AccreditationDocumentEntity,
    AccreditationSubmissionEntity,
};
use crate::services::{EmailService, ImageStorageService};
use crate::utils::authenticate;
use crate::AppState;
use axum::body::Bytes;

use crate::handlers::admin::validate_system_admin;
use crate::handlers::upload::build_storage_service;

// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------

fn format_dt(dt: &DateTime<Utc>) -> String {
    dt.to_rfc3339()
}

/// Validate that an accreditation status is one of the accepted values.
fn validate_accreditation_status(status: &str) -> Result<(), AppError> {
    match status {
        "none" | "pending" | "approved" | "rejected" | "expired" => Ok(()),
        other => Err(AppError::Validation(format!(
            "Invalid accreditation status '{}'. Expected one of: none, pending, approved, rejected, expired",
            other
        ))),
    }
}

// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------

/// Response for `GET /accreditation/status`
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccreditationStatusApiResponse {
    /// Current status: `"none"`, `"pending"`, `"approved"`, `"rejected"`, or `"expired"`.
    pub status: String,
    /// When the user was approved (ISO 8601), if applicable.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verified_at: Option<String>,
    /// When approval expires (ISO 8601), if applicable.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<String>,
    /// Current enforcement mode for accreditation.
    pub enforcement_mode: String,
}

/// Request body for `POST /accreditation/submit`
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmitAccreditationRequest {
    /// Verification method: `income`, `net_worth`, `credential`, `third_party_letter`,
    /// `insider`, or `investment_threshold`.
    pub method: String,
    /// `"individual"` or `"joint"` (income / net_worth methods)
    pub income_type: Option<String>,
    /// Stated income or net worth in USD
    pub stated_amount_usd: Option<f64>,
    /// FINRA CRD number (credential method)
    pub crd_number: Option<String>,
    /// `"series_7"`, `"series_65"`, or `"series_82"` (credential method)
    pub license_type: Option<String>,
    /// Investment commitment in USD (investment_threshold method)
    pub investment_commitment_usd: Option<f64>,
    /// `"individual"` or `"entity"`
    pub entity_type: Option<String>,
    /// Free-text user statement
    pub user_statement: Option<String>,
}

/// Response for `POST /accreditation/submit`
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmitAccreditationResponse {
    /// Internal submission ID (UUID).
    pub submission_id: String,
}

/// A single document entry
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccreditationDocumentItem {
    pub id: String,
    pub submission_id: String,
    pub document_type: String,
    pub s3_key: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub original_filename: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub file_size_bytes: Option<i64>,
    pub uploaded_at: String,
}

/// Response for `POST /accreditation/upload`
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentUploadResponse {
    pub document_id: String,
    pub submission_id: String,
    pub s3_key: String,
}

/// Response for `GET /admin/accreditation/documents/{doc_id}/url`
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentPresignedUrlResponse {
    /// Presigned GET URL valid for `expires_in` seconds.
    pub url: String,
    /// Number of seconds until the URL expires.
    pub expires_in: u32,
}

/// Allowed MIME types for accreditation documents.
///
/// Documents may be scanned images (JPEG/PNG/TIFF) or PDF/TIFF financial
/// records. GIF and WebP are excluded — they are not useful document formats.
const ALLOWED_DOC_CONTENT_TYPES: &[&str] = &[
    "application/pdf",
    "image/jpeg",
    "image/jpg",
    "image/png",
    "image/tiff",
    "image/tif",
];

/// A single submission item (for list and detail views)
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccreditationSubmissionItem {
    pub id: String,
    pub user_id: String,
    pub method: String,
    pub status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub income_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stated_amount_usd: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub crd_number: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub license_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub investment_commitment_usd: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub entity_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_statement: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reviewed_by: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reviewed_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reviewer_notes: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rejection_reason: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}

/// Response for `GET /accreditation/submissions`
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmissionsListResponse {
    pub submissions: Vec<AccreditationSubmissionItem>,
    pub total: u64,
    pub limit: u32,
    pub offset: u32,
}

/// Response for `GET /admin/accreditation/pending`
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingListResponse {
    pub submissions: Vec<AccreditationSubmissionItem>,
    pub total: u64,
    pub limit: u32,
    pub offset: u32,
}

/// Response for `GET /admin/users/{user_id}/accreditation`
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminUserAccreditationResponse {
    pub user_id: String,
    /// Current aggregate accreditation status on the user record.
    pub status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verified_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<String>,
    pub submissions: Vec<AccreditationSubmissionItem>,
    pub total_submissions: u64,
}

/// Response for `GET /admin/accreditation/{submission_id}`
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSubmissionDetailResponse {
    pub submission: AccreditationSubmissionItem,
    pub documents: Vec<AccreditationDocumentItem>,
}

/// Request body for `POST /admin/accreditation/{submission_id}/review`
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReviewAccreditationRequest {
    pub approved: bool,
    pub reviewer_notes: Option<String>,
    pub rejection_reason: Option<String>,
    /// Custom expiry in days from now (overrides the default setting).
    pub expiry_days: Option<i64>,
}

/// Request body for `POST /admin/users/{user_id}/accreditation/override`
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccreditationOverrideRequest {
    /// Target status: `"none"`, `"pending"`, `"approved"`, `"rejected"`, or `"expired"`.
    pub status: String,
    /// Optional expiry timestamp for `"approved"` status (ISO 8601).
    pub expires_at: Option<String>,
}

// ---------------------------------------------------------------------------
// Mapping helpers
// ---------------------------------------------------------------------------

fn map_submission(s: AccreditationSubmissionEntity) -> AccreditationSubmissionItem {
    AccreditationSubmissionItem {
        id: s.id.to_string(),
        user_id: s.user_id.to_string(),
        method: s.method,
        status: s.status,
        income_type: s.income_type,
        stated_amount_usd: s.stated_amount_usd,
        crd_number: s.crd_number,
        license_type: s.license_type,
        investment_commitment_usd: s.investment_commitment_usd,
        entity_type: s.entity_type,
        user_statement: s.user_statement,
        reviewed_by: s.reviewed_by.map(|u| u.to_string()),
        reviewed_at: s.reviewed_at.as_ref().map(format_dt),
        reviewer_notes: s.reviewer_notes,
        rejection_reason: s.rejection_reason,
        expires_at: s.expires_at.as_ref().map(format_dt),
        created_at: format_dt(&s.created_at),
        updated_at: format_dt(&s.updated_at),
    }
}

fn map_document(d: AccreditationDocumentEntity) -> AccreditationDocumentItem {
    AccreditationDocumentItem {
        id: d.id.to_string(),
        submission_id: d.submission_id.to_string(),
        document_type: d.document_type,
        s3_key: d.s3_key,
        original_filename: d.original_filename,
        content_type: d.content_type,
        file_size_bytes: d.file_size_bytes,
        uploaded_at: format_dt(&d.uploaded_at),
    }
}

// ---------------------------------------------------------------------------
// User-facing handlers
// ---------------------------------------------------------------------------

/// GET /accreditation/status
///
/// Returns the current accreditation status for the authenticated user,
/// including timestamps if approved.
///
/// # Errors
/// - `401 Unauthorized` — missing or invalid JWT.
/// - `404 Not Found` — accreditation service not configured.
pub async fn accreditation_status<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
) -> Result<Json<AccreditationStatusApiResponse>, AppError> {
    let auth_user = authenticate(&state, &headers).await?;

    let accreditation_service = state
        .accreditation_service
        .as_ref()
        .ok_or_else(|| AppError::NotFound("Accreditation not available".into()))?;

    let status = accreditation_service.get_status(auth_user.user_id).await?;

    Ok(Json(AccreditationStatusApiResponse {
        status: status.status,
        verified_at: status.verified_at.as_ref().map(format_dt),
        expires_at: status.expires_at.as_ref().map(format_dt),
        enforcement_mode: status.enforcement_mode,
    }))
}

/// POST /accreditation/submit
///
/// Creates a new accreditation submission for the authenticated user.
///
/// # Errors
/// - `401 Unauthorized` — missing or invalid JWT.
/// - `404 Not Found` — accreditation service not configured.
/// - `400 Validation` — invalid or missing method field.
pub async fn submit_accreditation<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    Json(request): Json<SubmitAccreditationRequest>,
) -> Result<Json<SubmitAccreditationResponse>, AppError> {
    let auth_user = authenticate(&state, &headers).await?;

    let accreditation_service = state
        .accreditation_service
        .as_ref()
        .ok_or_else(|| AppError::NotFound("Accreditation not available".into()))?;

    if request.method.is_empty() {
        return Err(AppError::Validation("method is required".into()));
    }

    let data = crate::services::SubmitAccreditationData {
        method: request.method,
        income_type: request.income_type,
        stated_amount_usd: request.stated_amount_usd,
        crd_number: request.crd_number,
        license_type: request.license_type,
        investment_commitment_usd: request.investment_commitment_usd,
        entity_type: request.entity_type,
        user_statement: request.user_statement,
    };

    let result = accreditation_service
        .submit_verification(auth_user.user_id, data)
        .await?;

    tracing::info!(
        user_id = %auth_user.user_id,
        submission_id = %result.submission_id,
        "Accreditation submission created"
    );

    Ok(Json(SubmitAccreditationResponse {
        submission_id: result.submission_id.to_string(),
    }))
}

/// POST /accreditation/upload
///
/// Accepts multipart/form-data with fields:
/// - `submissionId` — UUID of the submission to attach to
/// - `documentType` — document type string (e.g. `"tax_w2"`)
/// - `file` — the file bytes
///
/// # Errors
/// - `401 Unauthorized` — missing or invalid JWT.
/// - `404 Not Found` — accreditation service not configured or submission not found.
/// - `400 Validation` — missing fields or unsupported file type.
pub async fn upload_accreditation_document<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    mut multipart: Multipart,
) -> Result<Json<DocumentUploadResponse>, AppError> {
    let auth_user = authenticate(&state, &headers).await?;

    let accreditation_service = state
        .accreditation_service
        .as_ref()
        .ok_or_else(|| AppError::NotFound("Accreditation not available".into()))?;

    // Extract all multipart fields
    let mut submission_id_str: Option<String> = None;
    let mut document_type: Option<String> = None;
    let mut file_bytes: Option<Bytes> = None;
    let mut filename: Option<String> = None;
    let mut content_type_str: Option<String> = None;

    while let Some(field) = multipart
        .next_field()
        .await
        .map_err(|e| AppError::Validation(format!("Invalid multipart data: {}", e)))?
    {
        let field_name = field.name().unwrap_or("").to_string();
        match field_name.as_str() {
            "submissionId" => {
                let val = field.text().await.map_err(|e| {
                    AppError::Validation(format!("Failed to read submissionId: {}", e))
                })?;
                submission_id_str = Some(val);
            }
            "documentType" => {
                let val = field.text().await.map_err(|e| {
                    AppError::Validation(format!("Failed to read documentType: {}", e))
                })?;
                document_type = Some(val);
            }
            "file" => {
                filename = field.file_name().map(str::to_string);
                content_type_str = field.content_type().map(|ct| ct.to_string());
                let data = field
                    .bytes()
                    .await
                    .map_err(|e| AppError::Validation(format!("Failed to read file: {}", e)))?;
                file_bytes = Some(data);
            }
            _ => {
                // Consume and discard unknown fields
                let _ = field.bytes().await;
            }
        }
    }

    let submission_id_str =
        submission_id_str.ok_or_else(|| AppError::Validation("submissionId is required".into()))?;
    let submission_id = Uuid::parse_str(&submission_id_str)
        .map_err(|_| AppError::Validation("submissionId must be a valid UUID".into()))?;
    let doc_type =
        document_type.ok_or_else(|| AppError::Validation("documentType is required".into()))?;
    let file_data = file_bytes.ok_or_else(|| AppError::Validation("file is required".into()))?;

    // Validate content type (must be one of the allowed document formats)
    let content_type_validated = content_type_str
        .as_deref()
        .unwrap_or("application/octet-stream");
    if !ALLOWED_DOC_CONTENT_TYPES.contains(&content_type_validated) {
        return Err(AppError::Validation(format!(
            "Unsupported document type '{}'. Allowed: PDF, JPEG, PNG, TIFF.",
            content_type_validated
        )));
    }

    let file_size = file_data.len() as i64;
    let ext = filename
        .as_deref()
        .and_then(|f| f.rsplit('.').next())
        .unwrap_or("bin");
    let doc_id = Uuid::new_v4();
    let s3_key = format!(
        "accreditation/{}/{}/{}.{}",
        auth_user.user_id, submission_id, doc_id, ext
    );

    // Upload to S3 — fail if storage is configured, skip gracefully if not yet enabled.
    match build_storage_service(&state).await {
        Ok(storage) => {
            storage
                .upload_document(&s3_key, &file_data, content_type_validated)
                .await?;
        }
        Err(AppError::Validation(msg)) if msg.contains("not configured") => {
            // S3 not yet configured — store metadata only. Documents will not be
            // retrievable until image_storage settings are populated.
            tracing::warn!(
                s3_key = %s3_key,
                "Image storage not configured; document metadata stored without S3 upload"
            );
        }
        Err(e) => return Err(e),
    }

    let doc = accreditation_service
        .add_document(
            auth_user.user_id,
            submission_id,
            doc_type,
            s3_key.clone(),
            filename,
            content_type_str,
            Some(file_size),
        )
        .await?;

    tracing::info!(
        user_id = %auth_user.user_id,
        submission_id = %submission_id,
        document_id = %doc.id,
        s3_key = %s3_key,
        "Accreditation document uploaded"
    );

    Ok(Json(DocumentUploadResponse {
        document_id: doc.id.to_string(),
        submission_id: doc.submission_id.to_string(),
        s3_key,
    }))
}

/// GET /accreditation/submissions
///
/// Lists the authenticated user's own accreditation submissions.
///
/// # Errors
/// - `401 Unauthorized` — missing or invalid JWT.
/// - `404 Not Found` — accreditation service not configured.
pub async fn list_accreditation_submissions<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    Query(params): Query<ListUsersQueryParams>,
) -> Result<Json<SubmissionsListResponse>, AppError> {
    let auth_user = authenticate(&state, &headers).await?;

    let accreditation_service = state
        .accreditation_service
        .as_ref()
        .ok_or_else(|| AppError::NotFound("Accreditation not available".into()))?;

    let limit = cap_limit(params.limit);
    let offset = cap_offset(params.offset);

    let (submissions, total) = tokio::join!(
        accreditation_service.list_submissions(auth_user.user_id, limit, offset),
        accreditation_service.count_submissions(auth_user.user_id)
    );
    let submissions = submissions?;
    let total = total?;

    Ok(Json(SubmissionsListResponse {
        submissions: submissions.into_iter().map(map_submission).collect(),
        total,
        limit,
        offset,
    }))
}

// ---------------------------------------------------------------------------
// Admin handlers
// ---------------------------------------------------------------------------

/// GET /admin/accreditation/pending
///
/// Lists all submissions in `pending` status (oldest first), for admin review.
///
/// # Errors
/// - `401/403` — not authenticated or not a system admin.
/// - `404 Not Found` — accreditation service not configured.
pub async fn list_pending_accreditations<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    Query(params): Query<ListUsersQueryParams>,
) -> Result<Json<PendingListResponse>, AppError> {
    validate_system_admin(&state, &headers).await?;

    let accreditation_service = state
        .accreditation_service
        .as_ref()
        .ok_or_else(|| AppError::NotFound("Accreditation not available".into()))?;

    let limit = cap_limit(params.limit);
    let offset = cap_offset(params.offset);

    let (items, total) = accreditation_service
        .admin_list_pending(limit, offset)
        .await?;

    Ok(Json(PendingListResponse {
        submissions: items.into_iter().map(map_submission).collect(),
        total,
        limit,
        offset,
    }))
}

/// GET /admin/users/{user_id}/accreditation
///
/// Returns the full accreditation record for a user: aggregate status from the
/// user row plus paginated submission history (most recent 20 submissions).
///
/// # Errors
/// - `401/403` — not authenticated or not a system admin.
/// - `404 Not Found` — accreditation service not configured or user not found.
pub async fn get_user_accreditation<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    Path(user_id): Path<Uuid>,
) -> Result<Json<AdminUserAccreditationResponse>, AppError> {
    validate_system_admin(&state, &headers).await?;

    let accreditation_service = state
        .accreditation_service
        .as_ref()
        .ok_or_else(|| AppError::NotFound("Accreditation not available".into()))?;

    let user = state
        .user_repo
        .find_by_id(user_id)
        .await?
        .ok_or(AppError::NotFound("User not found".into()))?;

    let (submissions, total) = tokio::join!(
        accreditation_service.list_submissions(user_id, 20, 0),
        accreditation_service.count_submissions(user_id)
    );
    let submissions = submissions?;
    let total = total?;

    Ok(Json(AdminUserAccreditationResponse {
        user_id: user_id.to_string(),
        status: user.accreditation_status,
        verified_at: user.accreditation_verified_at.as_ref().map(format_dt),
        expires_at: user.accreditation_expires_at.as_ref().map(format_dt),
        submissions: submissions.into_iter().map(map_submission).collect(),
        total_submissions: total,
    }))
}

/// GET /admin/accreditation/{submission_id}
///
/// Returns full submission detail including the document list.
///
/// # Errors
/// - `401/403` — not authenticated or not a system admin.
/// - `404 Not Found` — submission not found.
pub async fn get_accreditation_submission<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    Path(submission_id): Path<Uuid>,
) -> Result<Json<AdminSubmissionDetailResponse>, AppError> {
    validate_system_admin(&state, &headers).await?;

    let accreditation_service = state
        .accreditation_service
        .as_ref()
        .ok_or_else(|| AppError::NotFound("Accreditation not available".into()))?;

    let submission = accreditation_service
        .admin_get_submission(submission_id)
        .await?
        .ok_or_else(|| AppError::NotFound("Submission not found".into()))?;

    let documents = accreditation_service
        .admin_list_documents(submission_id)
        .await?;

    Ok(Json(AdminSubmissionDetailResponse {
        submission: map_submission(submission),
        documents: documents.into_iter().map(map_document).collect(),
    }))
}

/// POST /admin/accreditation/{submission_id}/review
///
/// Approves or rejects an accreditation submission.
///
/// # Errors
/// - `401/403` — not authenticated or not a system admin.
/// - `404 Not Found` — submission not found.
pub async fn review_accreditation<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    Path(submission_id): Path<Uuid>,
    Json(request): Json<ReviewAccreditationRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
    let admin_id = validate_system_admin(&state, &headers).await?;

    let accreditation_service = state
        .accreditation_service
        .as_ref()
        .ok_or_else(|| AppError::NotFound("Accreditation not available".into()))?;

    // Verify submission exists
    let _ = accreditation_service
        .admin_get_submission(submission_id)
        .await?
        .ok_or_else(|| AppError::NotFound("Submission not found".into()))?;

    accreditation_service
        .admin_review(
            submission_id,
            admin_id,
            request.approved,
            request.reviewer_notes.clone(),
            request.rejection_reason.clone(),
            request.expiry_days.map(|d| d as u32),
        )
        .await?;

    let outcome = if request.approved {
        "approved"
    } else {
        "rejected"
    };
    tracing::info!(
        admin_id = %admin_id,
        submission_id = %submission_id,
        outcome,
        "Accreditation submission reviewed"
    );

    Ok(Json(serde_json::json!({
        "ok": true,
        "submissionId": submission_id.to_string(),
        "approved": request.approved,
    })))
}

/// POST /admin/users/{user_id}/accreditation/override
///
/// Manually sets the accreditation status for a user, bypassing the normal
/// review flow. Intended for support use-cases.
///
/// # Errors
/// - `401/403` — not authenticated or not a system admin.
/// - `400 Validation` — unrecognised `status` value or malformed `expires_at`.
/// - `404 Not Found` — user not found.
pub async fn override_accreditation_status<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    Path(user_id): Path<Uuid>,
    Json(request): Json<AccreditationOverrideRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
    let admin_id = validate_system_admin(&state, &headers).await?;

    validate_accreditation_status(&request.status)?;

    // Ensure the user exists before writing
    let _ = state
        .user_repo
        .find_by_id(user_id)
        .await?
        .ok_or(AppError::NotFound("User not found".into()))?;

    // Parse optional expires_at string
    let expires_at: Option<DateTime<Utc>> = match request.expires_at.as_deref() {
        None | Some("") => None,
        Some(s) => {
            let dt = DateTime::parse_from_rfc3339(s)
                .map_err(|_| {
                    AppError::Validation(format!("Invalid expires_at timestamp: '{}'", s))
                })?
                .with_timezone(&Utc);
            Some(dt)
        }
    };

    // Set verified_at to now when overriding to "approved", clear it otherwise
    let verified_at: Option<DateTime<Utc>> = if request.status == "approved" {
        Some(Utc::now())
    } else {
        None
    };

    state
        .user_repo
        .set_accreditation_status(user_id, &request.status, verified_at, expires_at)
        .await?;

    tracing::info!(
        admin_id = %admin_id,
        user_id = %user_id,
        status = %request.status,
        "Admin accreditation status override applied"
    );

    Ok(Json(serde_json::json!({
        "ok": true,
        "userId": user_id.to_string(),
        "status": request.status,
    })))
}

/// GET /admin/accreditation/documents/{doc_id}/url
///
/// Generates a short-lived presigned S3 GET URL for an accreditation document.
/// The URL expires after 15 minutes (900 seconds).
///
/// # Errors
/// - `401/403` — not authenticated or not a system admin.
/// - `404 Not Found` — document or accreditation service not configured.
/// - `500 Internal` — S3 not configured or presign failed.
pub async fn get_document_presigned_url<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    Path(doc_id): Path<Uuid>,
) -> Result<Json<DocumentPresignedUrlResponse>, AppError> {
    validate_system_admin(&state, &headers).await?;

    let accreditation_service = state
        .accreditation_service
        .as_ref()
        .ok_or_else(|| AppError::NotFound("Accreditation not available".into()))?;

    let doc = accreditation_service
        .admin_get_document(doc_id)
        .await?
        .ok_or_else(|| AppError::NotFound("Document not found".into()))?;

    const EXPIRY_SECS: u32 = 900; // 15 minutes

    let storage = build_storage_service(&state).await?;
    let url = storage.presign_get(&doc.s3_key, EXPIRY_SECS).await?;

    Ok(Json(DocumentPresignedUrlResponse {
        url,
        expires_in: EXPIRY_SECS,
    }))
}