Skip to main content

koprogo_api/infrastructure/database/repositories/
syndic_response_repository_impl.rs

1//! PostgreSQL implementation of [`SyndicResponseRepository`] (Story 3.7 —
2//! FR32 INV-23).
3//!
4//! Append-only by construction: only `save` writes new rows. There
5//! is no `update` / `delete` method on the trait. The DB trigger
6//! `syndic_responses_no_update` (cf. migration `20260605050000`) ensures
7//! the same guarantee at the SQL boundary.
8
9use crate::application::error::AppError;
10use crate::application::ports::SyndicResponseRepository;
11use crate::domain::entities::SyndicResponse;
12use crate::infrastructure::database::pool::DbPool;
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use sqlx::Row;
16use uuid::Uuid;
17
18pub struct PostgresSyndicResponseRepository {
19    pool: DbPool,
20}
21
22impl PostgresSyndicResponseRepository {
23    pub fn new(pool: DbPool) -> Self {
24        Self { pool }
25    }
26
27    fn row_to_response(row: &sqlx::postgres::PgRow) -> SyndicResponse {
28        SyndicResponse {
29            id: row.get("id"),
30            ticket_id: row.get("ticket_id"),
31            syndic_user_id: row.get("syndic_user_id"),
32            body: row.get("body"),
33            action_proposed: row.get("action_proposed"),
34            created_at: row.get("created_at"),
35        }
36    }
37}
38
39#[async_trait]
40impl SyndicResponseRepository for PostgresSyndicResponseRepository {
41    async fn save(&self, response: &SyndicResponse) -> Result<(), AppError> {
42        sqlx::query(
43            r#"
44            INSERT INTO syndic_responses (
45                id, ticket_id, syndic_user_id, body, action_proposed, created_at
46            )
47            VALUES ($1, $2, $3, $4, $5, $6)
48            "#,
49        )
50        .bind(response.id)
51        .bind(response.ticket_id)
52        .bind(response.syndic_user_id)
53        .bind(&response.body)
54        .bind(response.action_proposed.as_deref())
55        .bind(response.created_at)
56        .execute(&self.pool)
57        .await
58        .map_err(|e| AppError::Database(e.to_string()))?;
59        Ok(())
60    }
61
62    async fn list_for_ticket(&self, ticket_id: Uuid) -> Result<Vec<SyndicResponse>, AppError> {
63        let rows = sqlx::query(
64            r#"
65            SELECT id, ticket_id, syndic_user_id, body, action_proposed, created_at
66            FROM syndic_responses
67            WHERE ticket_id = $1
68            ORDER BY created_at ASC
69            "#,
70        )
71        .bind(ticket_id)
72        .fetch_all(&self.pool)
73        .await
74        .map_err(|e| AppError::Database(e.to_string()))?;
75
76        Ok(rows.iter().map(Self::row_to_response).collect())
77    }
78
79    async fn find_overdue_tickets(&self, now: DateTime<Utc>) -> Result<Vec<Uuid>, AppError> {
80        let rows = sqlx::query(
81            r#"
82            SELECT id
83            FROM tickets
84            WHERE sla_due_at IS NOT NULL
85              AND sla_due_at <= $1
86              AND sla_escalated_at IS NULL
87            "#,
88        )
89        .bind(now)
90        .fetch_all(&self.pool)
91        .await
92        .map_err(|e| AppError::Database(e.to_string()))?;
93
94        Ok(rows.iter().map(|r| r.get::<Uuid, _>("id")).collect())
95    }
96
97    async fn mark_ticket_escalated(
98        &self,
99        ticket_id: Uuid,
100        escalated_at: DateTime<Utc>,
101    ) -> Result<(), AppError> {
102        // Idempotency: only the first write succeeds (CAS on NULL).
103        sqlx::query(
104            r#"
105            UPDATE tickets
106            SET sla_escalated_at = $2
107            WHERE id = $1 AND sla_escalated_at IS NULL
108            "#,
109        )
110        .bind(ticket_id)
111        .bind(escalated_at)
112        .execute(&self.pool)
113        .await
114        .map_err(|e| AppError::Database(e.to_string()))?;
115        Ok(())
116    }
117}