Skip to main content

koprogo_api/infrastructure/database/repositories/
technical_spec_repository_impl.rs

1//! PostgreSQL implementation of [`TechnicalSpecRepository`] (Story 3.8 —
2//! FR33).
3//!
4//! All `sqlx::Error` paths are wrapped in `AppError::Database(_)` — no
5//! `Result<_, String>` debt (CRITICAL.md #4 / #555).
6//!
7//! Signatures are append-only: only `save_signature` writes new rows.
8//! The DB trigger `tech_spec_sig_no_mutation` (cf. migration
9//! `20260605060000`) blocks any UPDATE / DELETE at the SQL boundary.
10
11use crate::application::error::AppError;
12use crate::application::ports::TechnicalSpecRepository;
13use crate::domain::entities::{
14    SemVer, SignatoryRole, TechnicalSpec, TechnicalSpecSignature, TechnicalSpecStatus,
15};
16use crate::infrastructure::database::pool::DbPool;
17use async_trait::async_trait;
18use chrono::{DateTime, Utc};
19use sqlx::Row;
20use std::str::FromStr;
21use uuid::Uuid;
22
23pub struct PostgresTechnicalSpecRepository {
24    pool: DbPool,
25}
26
27impl PostgresTechnicalSpecRepository {
28    pub fn new(pool: DbPool) -> Self {
29        Self { pool }
30    }
31
32    fn row_to_spec(row: &sqlx::postgres::PgRow) -> Result<TechnicalSpec, AppError> {
33        let status_str: String = row.get("status");
34        let status = TechnicalSpecStatus::from_str(&status_str)?;
35        let required_str: Vec<String> = row.get("required_signatures");
36        let required_signatures: Result<Vec<SignatoryRole>, AppError> = required_str
37            .iter()
38            .map(|r| SignatoryRole::from_str(r))
39            .collect();
40
41        let major: i32 = row.get("version_major");
42        let minor: i32 = row.get("version_minor");
43        let patch: i32 = row.get("version_patch");
44        let version = SemVer::new(major as u32, minor as u32, patch as u32);
45
46        Ok(TechnicalSpec {
47            id: row.get("id"),
48            acp_id: row.get("acp_id"),
49            building_id: row.get("building_id"),
50            title: row.get("title"),
51            description: row.get("description"),
52            version,
53            status,
54            deliverables: row.get("deliverables"),
55            required_signatures: required_signatures?,
56            attachments: row.get("attachments"),
57            previous_version_id: row.get("previous_version_id"),
58            created_by: row.get("created_by"),
59            created_at: row.get("created_at"),
60            updated_at: row.get("updated_at"),
61        })
62    }
63
64    fn row_to_signature(row: &sqlx::postgres::PgRow) -> Result<TechnicalSpecSignature, AppError> {
65        let role_str: String = row.get("role");
66        let role = SignatoryRole::from_str(&role_str)?;
67        Ok(TechnicalSpecSignature {
68            id: row.get("id"),
69            technical_spec_id: row.get("technical_spec_id"),
70            signatory_user_id: row.get("signatory_user_id"),
71            role,
72            mandate_id: row.get("mandate_id"),
73            signed_at: row.get("signed_at"),
74        })
75    }
76}
77
78#[async_trait]
79impl TechnicalSpecRepository for PostgresTechnicalSpecRepository {
80    async fn save(&self, spec: &TechnicalSpec) -> Result<(), AppError> {
81        let required_strs: Vec<String> = spec
82            .required_signatures
83            .iter()
84            .map(|r| r.to_string())
85            .collect();
86        sqlx::query(
87            r#"
88            INSERT INTO technical_specs (
89                id, acp_id, building_id, title, description,
90                version_major, version_minor, version_patch,
91                status, deliverables, required_signatures, attachments,
92                previous_version_id, created_by, created_at, updated_at
93            )
94            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
95            "#,
96        )
97        .bind(spec.id)
98        .bind(spec.acp_id)
99        .bind(spec.building_id)
100        .bind(&spec.title)
101        .bind(&spec.description)
102        .bind(spec.version.major as i32)
103        .bind(spec.version.minor as i32)
104        .bind(spec.version.patch as i32)
105        .bind(spec.status.to_string())
106        .bind(&spec.deliverables)
107        .bind(&required_strs)
108        .bind(&spec.attachments)
109        .bind(spec.previous_version_id)
110        .bind(spec.created_by)
111        .bind(spec.created_at)
112        .bind(spec.updated_at)
113        .execute(&self.pool)
114        .await
115        .map_err(|e| AppError::Database(e.to_string()))?;
116        Ok(())
117    }
118
119    async fn update_status(
120        &self,
121        spec_id: Uuid,
122        status: &str,
123        updated_at: DateTime<Utc>,
124    ) -> Result<(), AppError> {
125        sqlx::query(
126            r#"
127            UPDATE technical_specs
128            SET status = $2, updated_at = $3
129            WHERE id = $1
130            "#,
131        )
132        .bind(spec_id)
133        .bind(status)
134        .bind(updated_at)
135        .execute(&self.pool)
136        .await
137        .map_err(|e| AppError::Database(e.to_string()))?;
138        Ok(())
139    }
140
141    async fn find_by_id(&self, id: Uuid) -> Result<Option<TechnicalSpec>, AppError> {
142        let row = sqlx::query(
143            r#"
144            SELECT id, acp_id, building_id, title, description,
145                   version_major, version_minor, version_patch,
146                   status, deliverables, required_signatures, attachments,
147                   previous_version_id, created_by, created_at, updated_at
148            FROM technical_specs
149            WHERE id = $1
150            "#,
151        )
152        .bind(id)
153        .fetch_optional(&self.pool)
154        .await
155        .map_err(|e| AppError::Database(e.to_string()))?;
156
157        match row {
158            None => Ok(None),
159            Some(row) => Ok(Some(Self::row_to_spec(&row)?)),
160        }
161    }
162
163    async fn list_for_acp(&self, acp_id: Uuid) -> Result<Vec<TechnicalSpec>, AppError> {
164        let rows = sqlx::query(
165            r#"
166            SELECT id, acp_id, building_id, title, description,
167                   version_major, version_minor, version_patch,
168                   status, deliverables, required_signatures, attachments,
169                   previous_version_id, created_by, created_at, updated_at
170            FROM technical_specs
171            WHERE acp_id = $1
172            ORDER BY created_at DESC
173            "#,
174        )
175        .bind(acp_id)
176        .fetch_all(&self.pool)
177        .await
178        .map_err(|e| AppError::Database(e.to_string()))?;
179
180        rows.iter().map(Self::row_to_spec).collect()
181    }
182
183    async fn save_signature(&self, sig: &TechnicalSpecSignature) -> Result<(), AppError> {
184        let res = sqlx::query(
185            r#"
186            INSERT INTO technical_spec_signatures (
187                id, technical_spec_id, signatory_user_id, role, mandate_id, signed_at
188            )
189            VALUES ($1, $2, $3, $4, $5, $6)
190            "#,
191        )
192        .bind(sig.id)
193        .bind(sig.technical_spec_id)
194        .bind(sig.signatory_user_id)
195        .bind(sig.role.to_string())
196        .bind(sig.mandate_id)
197        .bind(sig.signed_at)
198        .execute(&self.pool)
199        .await;
200
201        match res {
202            Ok(_) => Ok(()),
203            Err(sqlx::Error::Database(db_err)) => {
204                // PG unique_violation = 23505.
205                if db_err.code().as_deref() == Some("23505") {
206                    Err(AppError::SignatureAlreadyExists)
207                } else {
208                    Err(AppError::Database(db_err.to_string()))
209                }
210            }
211            Err(e) => Err(AppError::Database(e.to_string())),
212        }
213    }
214
215    async fn list_signatures_for_spec(
216        &self,
217        spec_id: Uuid,
218    ) -> Result<Vec<TechnicalSpecSignature>, AppError> {
219        let rows = sqlx::query(
220            r#"
221            SELECT id, technical_spec_id, signatory_user_id, role, mandate_id, signed_at
222            FROM technical_spec_signatures
223            WHERE technical_spec_id = $1
224            ORDER BY signed_at ASC
225            "#,
226        )
227        .bind(spec_id)
228        .fetch_all(&self.pool)
229        .await
230        .map_err(|e| AppError::Database(e.to_string()))?;
231
232        rows.iter().map(Self::row_to_signature).collect()
233    }
234}