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 async fn save_magic_token(&self, token: &MagicToken) -> Result<(), torii_core::Error> {
52 let row = PostgresMagicToken::from(token);
53
54 sqlx::query("INSERT INTO magic_links (user_id, token, expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)")
55 .bind(row.user_id)
56 .bind(row.token)
57 .bind(row.expires_at)
58 .bind(row.created_at)
59 .bind(row.updated_at)
60 .execute(&self.pool)
61 .await
62 .map_err(|e| StorageError::Database(e.to_string()))?;
63
64 Ok(())
65 }
66
67 async fn get_magic_token(&self, token: &str) -> Result<Option<MagicToken>, torii_core::Error> {
68 let row: Option<PostgresMagicToken> =
69 sqlx::query_as(
70 "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",
71 )
72 .bind(token)
73 .bind(Utc::now())
74 .fetch_optional(&self.pool)
75 .await
76 .map_err(|e| StorageError::Database(e.to_string()))?;
77
78 Ok(row.map(|row| row.into()))
79 }
80
81 async fn set_magic_token_used(&self, token: &str) -> Result<(), torii_core::Error> {
82 sqlx::query("UPDATE magic_links SET used_at = $1 WHERE token = $2")
83 .bind(Utc::now())
84 .bind(token)
85 .execute(&self.pool)
86 .await
87 .map_err(|e| StorageError::Database(e.to_string()))?;
88
89 Ok(())
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 use chrono::Duration;
98 use torii_core::{NewUser, User, UserStorage, storage::MagicLinkStorage};
99 use uuid::Uuid;
100
101 use crate::PostgresStorage;
102
103 async fn create_test_user(storage: &PostgresStorage) -> User {
104 let user = NewUser::builder()
105 .email("test@test.com".to_string())
106 .build()
107 .expect("Failed to build test user");
108 storage
109 .create_user(&user)
110 .await
111 .expect("Failed to create test user")
112 }
113
114 #[tokio::test]
115 async fn test_save_and_get_magic_token() {
116 let storage = crate::tests::setup_test_db().await;
117
118 let user = create_test_user(&storage).await;
120
121 let token = MagicToken::new(
122 UserId::new(&user.id.to_string()),
123 Uuid::new_v4().to_string(),
124 None,
125 Utc::now() + Duration::minutes(5),
126 Utc::now(),
127 Utc::now(),
128 );
129 storage
130 .save_magic_token(&token)
131 .await
132 .expect("Failed to save magic token");
133
134 let stored_token = storage
135 .get_magic_token(&token.token)
136 .await
137 .expect("Failed to get magic token");
138 assert!(stored_token.is_some());
139
140 let stored_token = stored_token.unwrap();
141 assert_eq!(stored_token, token);
142 }
143
144 #[tokio::test]
145 async fn test_get_nonexistent_magic_token() {
146 let storage = crate::tests::setup_test_db().await;
147
148 let token = Uuid::new_v4().to_string();
149 let result = storage
150 .get_magic_token(&token)
151 .await
152 .expect("Failed to query magic token");
153 assert!(result.is_none());
154 }
155}