torii_storage_postgres/
magic_link.rs1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use torii_core::{
4 UserId,
5 error::StorageError,
6 storage::{MagicLinkStorage, MagicToken},
7};
8
9use crate::PostgresStorage;
10
11#[derive(Debug, Clone, sqlx::FromRow)]
12pub struct PostgresMagicToken {
13 pub id: Option<i64>,
14 pub user_id: String,
15 pub token: String,
16 pub used_at: Option<DateTime<Utc>>,
17 pub expires_at: DateTime<Utc>,
18 pub created_at: DateTime<Utc>,
19 pub updated_at: DateTime<Utc>,
20}
21
22impl From<PostgresMagicToken> for MagicToken {
23 fn from(row: PostgresMagicToken) -> Self {
24 MagicToken::new(
25 UserId::new(&row.user_id),
26 row.token.clone(),
27 row.used_at,
28 row.expires_at,
29 row.created_at,
30 row.updated_at,
31 )
32 }
33}
34
35impl From<&MagicToken> for PostgresMagicToken {
36 fn from(token: &MagicToken) -> Self {
37 PostgresMagicToken {
38 id: None,
39 user_id: token.user_id.as_str().to_string(),
40 token: token.token.clone(),
41 used_at: token.used_at,
42 expires_at: token.expires_at,
43 created_at: token.created_at,
44 updated_at: token.updated_at,
45 }
46 }
47}
48
49#[async_trait]
50impl MagicLinkStorage for PostgresStorage {
51 type Error = StorageError;
52
53 async fn save_magic_token(
54 &self,
55 token: &MagicToken,
56 ) -> Result<(), <Self as MagicLinkStorage>::Error> {
57 let row = PostgresMagicToken::from(token);
58
59 sqlx::query("INSERT INTO magic_links (user_id, token, expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)")
60 .bind(row.user_id)
61 .bind(row.token)
62 .bind(row.expires_at)
63 .bind(row.created_at)
64 .bind(row.updated_at)
65 .execute(&self.pool)
66 .await
67 .map_err(|e| StorageError::Database(e.to_string()))?;
68
69 Ok(())
70 }
71
72 async fn get_magic_token(
73 &self,
74 token: &str,
75 ) -> Result<Option<MagicToken>, <Self as MagicLinkStorage>::Error> {
76 let row: Option<PostgresMagicToken> =
77 sqlx::query_as(
78 "SELECT id, user_id, token, used_at, expires_at, created_at, updated_at FROM magic_links WHERE token = $1 AND expires_at > $2 AND used_at IS NULL",
79 )
80 .bind(token)
81 .bind(Utc::now())
82 .fetch_optional(&self.pool)
83 .await
84 .map_err(|e| StorageError::Database(e.to_string()))?;
85
86 Ok(row.map(|row| row.into()))
87 }
88
89 async fn set_magic_token_used(
90 &self,
91 token: &str,
92 ) -> Result<(), <Self as MagicLinkStorage>::Error> {
93 sqlx::query("UPDATE magic_links SET used_at = $1 WHERE token = $2")
94 .bind(Utc::now())
95 .bind(token)
96 .execute(&self.pool)
97 .await
98 .map_err(|e| StorageError::Database(e.to_string()))?;
99
100 Ok(())
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 use chrono::Duration;
109 use torii_core::{NewUser, User, UserStorage, storage::MagicLinkStorage};
110 use uuid::Uuid;
111
112 use crate::PostgresStorage;
113
114 async fn create_test_user(storage: &PostgresStorage) -> User {
115 let user = NewUser::builder()
116 .email("test@test.com".to_string())
117 .build()
118 .expect("Failed to build test user");
119 storage
120 .create_user(&user)
121 .await
122 .expect("Failed to create test user")
123 }
124
125 #[tokio::test]
126 async fn test_save_and_get_magic_token() {
127 let storage = crate::tests::setup_test_db().await;
128
129 let user = create_test_user(&storage).await;
131
132 let token = MagicToken::new(
133 UserId::new(&user.id.to_string()),
134 Uuid::new_v4().to_string(),
135 None,
136 Utc::now() + Duration::minutes(5),
137 Utc::now(),
138 Utc::now(),
139 );
140 storage
141 .save_magic_token(&token)
142 .await
143 .expect("Failed to save magic token");
144
145 let stored_token = storage
146 .get_magic_token(&token.token)
147 .await
148 .expect("Failed to get magic token");
149 assert!(stored_token.is_some());
150
151 let stored_token = stored_token.unwrap();
152 assert_eq!(stored_token, token);
153 }
154
155 #[tokio::test]
156 async fn test_get_nonexistent_magic_token() {
157 let storage = crate::tests::setup_test_db().await;
158
159 let token = Uuid::new_v4().to_string();
160 let result = storage
161 .get_magic_token(&token)
162 .await
163 .expect("Failed to query magic token");
164 assert!(result.is_none());
165 }
166}