Skip to main content

koprogo_api/infrastructure/database/repositories/
contractor_evaluation_repository_impl.rs

1//! PostgreSQL implementation of [`ContractorEvaluationRepository`] (Story
2//! 3.9 — FR34 FR35 INV-21 INV-24).
3//!
4//! All `sqlx::Error` paths are wrapped in `AppError::Database(_)` — no
5//! `Result<_, String>` debt (CRITICAL.md #4 / #555).
6//!
7//! Evaluations are append-only: only `save` writes new rows. The DB
8//! trigger `contractor_eval_no_mutation` (cf. migration `20260605070000`)
9//! blocks any UPDATE / DELETE at the SQL boundary, so a misbehaving caller
10//! cannot tamper with the audit trail.
11
12use crate::application::error::AppError;
13use crate::application::ports::ContractorEvaluationRepository;
14use crate::domain::entities::{ContractorEvaluation, EvaluationScores};
15use crate::infrastructure::database::pool::DbPool;
16use async_trait::async_trait;
17use sqlx::Row;
18use uuid::Uuid;
19
20pub struct PostgresContractorEvaluationRepository {
21    pool: DbPool,
22}
23
24impl PostgresContractorEvaluationRepository {
25    pub fn new(pool: DbPool) -> Self {
26        Self { pool }
27    }
28
29    fn row_to_evaluation(row: &sqlx::postgres::PgRow) -> Result<ContractorEvaluation, AppError> {
30        let quality: i16 = row.get("score_quality");
31        let timeliness: i16 = row.get("score_timeliness");
32        let communication: i16 = row.get("score_communication");
33        let cost_compliance: i16 = row.get("score_cost_compliance");
34        let overall: i16 = row.get("score_overall");
35        Ok(ContractorEvaluation {
36            id: row.get("id"),
37            contractor_user_id: row.get("contractor_user_id"),
38            technical_spec_id: row.get("technical_spec_id"),
39            linked_ticket_ids: row.get("linked_ticket_ids"),
40            evaluator_user_id: row.get("evaluator_user_id"),
41            scores: EvaluationScores {
42                quality: quality as u8,
43                timeliness: timeliness as u8,
44                communication: communication as u8,
45                cost_compliance: cost_compliance as u8,
46                overall: overall as u8,
47            },
48            comment: row.get("comment"),
49            created_at: row.get("created_at"),
50        })
51    }
52}
53
54#[async_trait]
55impl ContractorEvaluationRepository for PostgresContractorEvaluationRepository {
56    async fn save(&self, e: &ContractorEvaluation) -> Result<(), AppError> {
57        sqlx::query(
58            r#"
59            INSERT INTO contractor_evaluations (
60                id, contractor_user_id, technical_spec_id, linked_ticket_ids,
61                evaluator_user_id,
62                score_quality, score_timeliness, score_communication,
63                score_cost_compliance, score_overall,
64                comment, created_at
65            )
66            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
67            "#,
68        )
69        .bind(e.id)
70        .bind(e.contractor_user_id)
71        .bind(e.technical_spec_id)
72        .bind(&e.linked_ticket_ids)
73        .bind(e.evaluator_user_id)
74        .bind(e.scores.quality as i16)
75        .bind(e.scores.timeliness as i16)
76        .bind(e.scores.communication as i16)
77        .bind(e.scores.cost_compliance as i16)
78        .bind(e.scores.overall as i16)
79        .bind(&e.comment)
80        .bind(e.created_at)
81        .execute(&self.pool)
82        .await
83        .map_err(|err| AppError::Database(err.to_string()))?;
84        Ok(())
85    }
86
87    async fn find_by_id(&self, id: Uuid) -> Result<Option<ContractorEvaluation>, AppError> {
88        let row = sqlx::query(
89            r#"
90            SELECT id, contractor_user_id, technical_spec_id, linked_ticket_ids,
91                   evaluator_user_id,
92                   score_quality, score_timeliness, score_communication,
93                   score_cost_compliance, score_overall,
94                   comment, created_at
95            FROM contractor_evaluations
96            WHERE id = $1
97            "#,
98        )
99        .bind(id)
100        .fetch_optional(&self.pool)
101        .await
102        .map_err(|e| AppError::Database(e.to_string()))?;
103
104        match row {
105            None => Ok(None),
106            Some(row) => Ok(Some(Self::row_to_evaluation(&row)?)),
107        }
108    }
109
110    async fn list_for_contractor(
111        &self,
112        contractor_user_id: Uuid,
113    ) -> Result<Vec<ContractorEvaluation>, AppError> {
114        let rows = sqlx::query(
115            r#"
116            SELECT id, contractor_user_id, technical_spec_id, linked_ticket_ids,
117                   evaluator_user_id,
118                   score_quality, score_timeliness, score_communication,
119                   score_cost_compliance, score_overall,
120                   comment, created_at
121            FROM contractor_evaluations
122            WHERE contractor_user_id = $1
123            ORDER BY created_at DESC
124            "#,
125        )
126        .bind(contractor_user_id)
127        .fetch_all(&self.pool)
128        .await
129        .map_err(|e| AppError::Database(e.to_string()))?;
130
131        rows.iter().map(Self::row_to_evaluation).collect()
132    }
133}