Skip to main content

koprogo_api/infrastructure/database/repositories/
magic_link_repository_impl.rs

1//! PostgreSQL implementation of [`MagicLinkRepository`] (Story 3.2).
2//!
3//! All `sqlx::Error` paths are wrapped in `AppError::Database(_)` — no
4//! `Result<_, String>` debt (CRITICAL.md #4, issue #555).
5
6use crate::application::error::AppError;
7use crate::application::ports::MagicLinkRepository;
8use crate::domain::entities::{MagicLink, MagicLinkScopeKind};
9use crate::infrastructure::database::pool::DbPool;
10use async_trait::async_trait;
11use sqlx::Row;
12use std::str::FromStr;
13use uuid::Uuid;
14
15pub struct PostgresMagicLinkRepository {
16    pool: DbPool,
17}
18
19impl PostgresMagicLinkRepository {
20    pub fn new(pool: DbPool) -> Self {
21        Self { pool }
22    }
23}
24
25#[async_trait]
26impl MagicLinkRepository for PostgresMagicLinkRepository {
27    async fn save(&self, link: &MagicLink) -> Result<(), AppError> {
28        sqlx::query(
29            r#"
30            INSERT INTO magic_links (
31                id, token_hash, subject_user_id, scope_kind, scope_id,
32                issued_by, expires_at, consumed_at, created_at, updated_at
33            )
34            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
35            "#,
36        )
37        .bind(link.id)
38        .bind(&link.token_hash)
39        // ── Le sentinel « pas de sujet » devient NULL à la frontière ───────
40        //
41        // Le domaine dit « ce lien n'a pas de sujet » avec `Uuid::nil()` :
42        // c'est le choix de #815 et #835, documenté dans
43        // `contractor_report_use_cases.rs:352` — « le prestataire n'a souvent
44        // PAS de compte, la voie nominale est le lien ».
45        //
46        // La base, elle, porte `REFERENCES users(id)`. Elle entendait donc
47        // « CE sujet-là », qui n'existe pas :
48        //
49        //     insert or update on table "magic_links" violates foreign key
50        //     constraint "magic_links_subject_user_id_fkey"
51        //
52        // Traduire ici est le rôle d'un adaptateur : le domaine garde son
53        // vocabulaire, le schéma garde son intégrité référentielle pour les
54        // liens qui ONT un sujet, et `NULL` dit la vérité pour les autres.
55        //
56        // L'alternative — créer un utilisateur fantôme d'UUID nul — ferait
57        // apparaître un compte dans les listes, les journaux d'audit et les
58        // décomptes, et quelqu'un finirait par lui attribuer un rôle.
59        .bind(if link.subject_user_id.is_nil() {
60            None
61        } else {
62            Some(link.subject_user_id)
63        })
64        .bind(link.scope_kind.to_string())
65        .bind(link.scope_id)
66        .bind(link.issued_by)
67        .bind(link.expires_at)
68        .bind(link.consumed_at)
69        .bind(link.created_at)
70        .bind(link.updated_at)
71        .execute(&self.pool)
72        .await
73        .map_err(|e| AppError::Database(e.to_string()))?;
74
75        Ok(())
76    }
77
78    async fn find_by_token_hash(&self, token_hash: &str) -> Result<Option<MagicLink>, AppError> {
79        let row = sqlx::query(
80            r#"
81            SELECT id, token_hash, subject_user_id, scope_kind, scope_id,
82                   issued_by, expires_at, consumed_at, created_at, updated_at
83            FROM magic_links
84            WHERE token_hash = $1
85            "#,
86        )
87        .bind(token_hash)
88        .fetch_optional(&self.pool)
89        .await
90        .map_err(|e| AppError::Database(e.to_string()))?;
91
92        match row {
93            None => Ok(None),
94            Some(row) => {
95                let scope_kind_str: String = row.get("scope_kind");
96                let scope_kind = MagicLinkScopeKind::from_str(&scope_kind_str)?;
97                Ok(Some(MagicLink {
98                    id: row.get("id"),
99                    token_hash: row.get("token_hash"),
100                    // Le retour du trajet : `NULL` redevient le sentinel que
101                    // le domaine connaît. La traduction est symétrique, sans
102                    // quoi un lien sans sujet se relirait différemment de ce
103                    // qu'il a été écrit.
104                    subject_user_id: row
105                        .get::<Option<Uuid>, _>("subject_user_id")
106                        .unwrap_or_else(Uuid::nil),
107                    scope_kind,
108                    scope_id: row.get("scope_id"),
109                    issued_by: row.get("issued_by"),
110                    expires_at: row.get("expires_at"),
111                    consumed_at: row.get("consumed_at"),
112                    created_at: row.get("created_at"),
113                    updated_at: row.get("updated_at"),
114                }))
115            }
116        }
117    }
118
119    async fn mark_consumed(&self, id: Uuid) -> Result<(), AppError> {
120        // Atomic single-use guard: only updates rows that have NOT been consumed.
121        // Concurrent attempts will see rows_affected == 0 for the loser.
122        let result = sqlx::query(
123            r#"
124            UPDATE magic_links
125            SET consumed_at = NOW(), updated_at = NOW()
126            WHERE id = $1 AND consumed_at IS NULL
127            "#,
128        )
129        .bind(id)
130        .execute(&self.pool)
131        .await
132        .map_err(|e| AppError::Database(e.to_string()))?;
133
134        if result.rows_affected() == 0 {
135            return Err(AppError::MagicLinkAlreadyConsumed);
136        }
137        Ok(())
138    }
139}