Skip to main content

koprogo_api/infrastructure/database/repositories/
mandate_repository_impl.rs

1//! PostgreSQL implementation of [`MandateRepository`] (Story 3.4).
2//!
3//! All `sqlx::Error` paths are wrapped in `AppError::Database(_)` — no
4//! `Result<_, String>` debt (CRITICAL.md #4 / #555).
5
6use crate::application::error::AppError;
7use crate::application::ports::MandateRepository;
8use crate::domain::entities::{Mandate, MandateKind, MandateScope};
9use crate::infrastructure::database::pool::DbPool;
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use sqlx::Row;
13use std::str::FromStr;
14use uuid::Uuid;
15
16pub struct PostgresMandateRepository {
17    pool: DbPool,
18}
19
20impl PostgresMandateRepository {
21    pub fn new(pool: DbPool) -> Self {
22        Self { pool }
23    }
24
25    fn row_to_mandate(row: &sqlx::postgres::PgRow) -> Result<Mandate, AppError> {
26        let kind_str: String = row.get("kind");
27        let kind = MandateKind::from_str(&kind_str)?;
28        let scope_kind_str: String = row.get("scope_kind");
29        let scope_id: Uuid = row.get("scope_id");
30        let scope = MandateScope::from_parts(&scope_kind_str, scope_id)?;
31        Ok(Mandate {
32            id: row.get("id"),
33            subject_user_id: row.get("subject_user_id"),
34            kind,
35            scope,
36            issued_by: row.get("issued_by"),
37            reason: row.get("reason"),
38            valid_from: row.get("valid_from"),
39            valid_until: row.get("valid_until"),
40            revoked_at: row.get("revoked_at"),
41            created_at: row.get("created_at"),
42            updated_at: row.get("updated_at"),
43        })
44    }
45}
46
47#[async_trait]
48impl MandateRepository for PostgresMandateRepository {
49    async fn save(&self, mandate: &Mandate) -> Result<(), AppError> {
50        sqlx::query(
51            r#"
52            INSERT INTO mandates (
53                id, subject_user_id, kind, scope_kind, scope_id,
54                issued_by, reason, valid_from, valid_until, revoked_at,
55                created_at, updated_at
56            )
57            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
58            "#,
59        )
60        .bind(mandate.id)
61        .bind(mandate.subject_user_id)
62        .bind(mandate.kind.to_string())
63        .bind(mandate.scope.kind_str())
64        .bind(mandate.scope.id())
65        .bind(mandate.issued_by)
66        .bind(&mandate.reason)
67        .bind(mandate.valid_from)
68        .bind(mandate.valid_until)
69        .bind(mandate.revoked_at)
70        .bind(mandate.created_at)
71        .bind(mandate.updated_at)
72        .execute(&self.pool)
73        .await
74        .map_err(|e| AppError::Database(e.to_string()))?;
75        Ok(())
76    }
77
78    async fn find_by_id(&self, id: Uuid) -> Result<Option<Mandate>, AppError> {
79        let row = sqlx::query(
80            r#"
81            SELECT id, subject_user_id, kind, scope_kind, scope_id,
82                   issued_by, reason, valid_from, valid_until, revoked_at,
83                   created_at, updated_at
84            FROM mandates
85            WHERE id = $1
86            "#,
87        )
88        .bind(id)
89        .fetch_optional(&self.pool)
90        .await
91        .map_err(|e| AppError::Database(e.to_string()))?;
92
93        match row {
94            None => Ok(None),
95            Some(row) => Ok(Some(Self::row_to_mandate(&row)?)),
96        }
97    }
98
99    async fn list_active_for_subject(
100        &self,
101        subject_user_id: Uuid,
102    ) -> Result<Vec<Mandate>, AppError> {
103        let rows = sqlx::query(
104            r#"
105            SELECT id, subject_user_id, kind, scope_kind, scope_id,
106                   issued_by, reason, valid_from, valid_until, revoked_at,
107                   created_at, updated_at
108            FROM mandates
109            WHERE subject_user_id = $1
110              AND revoked_at IS NULL
111              AND valid_from <= NOW()
112              AND valid_until > NOW()
113            ORDER BY valid_until ASC
114            "#,
115        )
116        .bind(subject_user_id)
117        .fetch_all(&self.pool)
118        .await
119        .map_err(|e| AppError::Database(e.to_string()))?;
120
121        rows.iter().map(Self::row_to_mandate).collect()
122    }
123
124    async fn list_for_scope(&self, scope: &MandateScope) -> Result<Vec<Mandate>, AppError> {
125        let rows = sqlx::query(
126            r#"
127            SELECT id, subject_user_id, kind, scope_kind, scope_id,
128                   issued_by, reason, valid_from, valid_until, revoked_at,
129                   created_at, updated_at
130            FROM mandates
131            WHERE scope_kind = $1 AND scope_id = $2
132            ORDER BY created_at DESC
133            "#,
134        )
135        .bind(scope.kind_str())
136        .bind(scope.id())
137        .fetch_all(&self.pool)
138        .await
139        .map_err(|e| AppError::Database(e.to_string()))?;
140
141        rows.iter().map(Self::row_to_mandate).collect()
142    }
143
144    async fn revoke(&self, id: Uuid, revoked_at: DateTime<Utc>) -> Result<(), AppError> {
145        // Idempotent: only updates rows that are not already revoked.
146        sqlx::query(
147            r#"
148            UPDATE mandates
149            SET revoked_at = $2, updated_at = $2
150            WHERE id = $1 AND revoked_at IS NULL
151            "#,
152        )
153        .bind(id)
154        .bind(revoked_at)
155        .execute(&self.pool)
156        .await
157        .map_err(|e| AppError::Database(e.to_string()))?;
158        Ok(())
159    }
160}