koprogo_api/infrastructure/database/repositories/
magic_link_repository_impl.rs1use 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 .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 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 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}