Skip to main content

systemprompt_users/models/
mod.rs

1//! Data types for the users domain.
2//!
3//! Defines the persisted [`User`] record and its projections
4//! ([`UserActivity`], [`UserWithSessions`], [`UserStats`],
5//! [`UserCountBreakdown`], [`UserExport`]), session rows
6//! ([`UserSession`], [`UserSessionRow`]), and the credential records
7//! [`UserApiKey`] / [`NewApiKey`] and [`UserDeviceCert`]. Role and status
8//! enums are re-exported from `systemprompt_models::auth`.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use sqlx::FromRow;
16use systemprompt_identifiers::{ApiKeyId, DeviceCertId, SessionId, UserId};
17
18pub use systemprompt_models::auth::{UserRole, UserStatus};
19
20#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
21pub struct User {
22    #[sqlx(try_from = "String")]
23    pub id: UserId,
24    pub name: String,
25    pub email: String,
26    pub full_name: Option<String>,
27    pub display_name: Option<String>,
28    pub status: Option<String>,
29    pub email_verified: Option<bool>,
30    pub roles: Vec<String>,
31    pub avatar_url: Option<String>,
32    pub is_bot: bool,
33    pub is_scanner: bool,
34    pub created_at: Option<DateTime<Utc>>,
35    pub updated_at: Option<DateTime<Utc>>,
36}
37
38#[must_use]
39pub fn normalise_email(email: &str) -> String {
40    email.trim().to_lowercase()
41}
42
43impl User {
44    pub fn is_active(&self) -> bool {
45        self.status.as_deref() == Some(UserStatus::Active.as_str())
46    }
47
48    pub fn is_admin(&self) -> bool {
49        self.roles.contains(&UserRole::Admin.as_str().to_owned())
50    }
51
52    pub fn has_role(&self, role: UserRole) -> bool {
53        self.roles.contains(&role.as_str().to_owned())
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
58pub struct UserActivity {
59    #[sqlx(try_from = "String")]
60    pub user_id: UserId,
61    pub last_active: Option<DateTime<Utc>>,
62    pub session_count: i64,
63    pub task_count: i64,
64    pub message_count: i64,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
68pub struct UserWithSessions {
69    #[sqlx(try_from = "String")]
70    pub id: UserId,
71    pub name: String,
72    pub email: String,
73    pub full_name: Option<String>,
74    pub status: Option<String>,
75    pub roles: Vec<String>,
76    pub created_at: Option<DateTime<Utc>>,
77    pub active_sessions: i64,
78    pub last_session_at: Option<DateTime<Utc>>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct UserSession {
83    pub session_id: SessionId,
84    pub user_id: Option<UserId>,
85    pub ip_address: Option<String>,
86    pub user_agent: Option<String>,
87    pub device_type: Option<String>,
88    pub started_at: Option<DateTime<Utc>>,
89    pub last_activity_at: Option<DateTime<Utc>>,
90    pub ended_at: Option<DateTime<Utc>>,
91}
92
93#[derive(Debug, Clone, FromRow)]
94pub struct UserSessionRow {
95    #[sqlx(try_from = "String")]
96    pub session_id: SessionId,
97    pub user_id: Option<UserId>,
98    pub ip_address: Option<String>,
99    pub user_agent: Option<String>,
100    pub device_type: Option<String>,
101    pub started_at: Option<DateTime<Utc>>,
102    pub last_activity_at: Option<DateTime<Utc>>,
103    pub ended_at: Option<DateTime<Utc>>,
104}
105
106impl From<UserSessionRow> for UserSession {
107    fn from(row: UserSessionRow) -> Self {
108        Self {
109            session_id: row.session_id,
110            user_id: row.user_id,
111            ip_address: row.ip_address,
112            user_agent: row.user_agent,
113            device_type: row.device_type,
114            started_at: row.started_at,
115            last_activity_at: row.last_activity_at,
116            ended_at: row.ended_at,
117        }
118    }
119}
120
121#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
122pub struct UserStats {
123    pub total: i64,
124    pub created_24h: i64,
125    pub created_7d: i64,
126    pub created_30d: i64,
127    pub active: i64,
128    pub suspended: i64,
129    pub admins: i64,
130    pub anonymous: i64,
131    pub bots: i64,
132    pub oldest_user: Option<DateTime<Utc>>,
133    pub newest_user: Option<DateTime<Utc>>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct UserCountBreakdown {
138    pub total: i64,
139    pub by_status: std::collections::HashMap<String, i64>,
140    pub by_role: std::collections::HashMap<String, i64>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct UserExport {
145    pub id: UserId,
146    pub name: String,
147    pub email: String,
148    pub full_name: Option<String>,
149    pub display_name: Option<String>,
150    pub status: Option<String>,
151    pub email_verified: Option<bool>,
152    pub roles: Vec<String>,
153    pub is_bot: bool,
154    pub is_scanner: bool,
155    pub created_at: Option<DateTime<Utc>>,
156    pub updated_at: Option<DateTime<Utc>>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
160pub struct UserApiKey {
161    #[sqlx(try_from = "String")]
162    pub id: ApiKeyId,
163    #[sqlx(try_from = "String")]
164    pub user_id: UserId,
165    pub name: String,
166    pub key_prefix: String,
167    pub key_hash: String,
168    pub created_at: Option<DateTime<Utc>>,
169    pub last_used_at: Option<DateTime<Utc>>,
170    pub expires_at: Option<DateTime<Utc>>,
171    pub revoked_at: Option<DateTime<Utc>>,
172}
173
174impl UserApiKey {
175    pub fn is_active(&self, now: DateTime<Utc>) -> bool {
176        if self.revoked_at.is_some() {
177            return false;
178        }
179        if let Some(expires_at) = self.expires_at
180            && now >= expires_at
181        {
182            return false;
183        }
184        true
185    }
186}
187
188#[derive(Debug, Clone)]
189pub struct NewApiKey {
190    pub record: UserApiKey,
191    pub secret: String,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
195pub struct UserDeviceCert {
196    #[sqlx(try_from = "String")]
197    pub id: DeviceCertId,
198    #[sqlx(try_from = "String")]
199    pub user_id: UserId,
200    pub fingerprint: String,
201    pub label: String,
202    pub enrolled_at: Option<DateTime<Utc>>,
203    pub revoked_at: Option<DateTime<Utc>>,
204}
205
206impl UserDeviceCert {
207    pub const fn is_active(&self) -> bool {
208        self.revoked_at.is_none()
209    }
210}
211
212impl From<User> for UserExport {
213    fn from(user: User) -> Self {
214        Self {
215            id: user.id,
216            name: user.name,
217            email: user.email,
218            full_name: user.full_name,
219            display_name: user.display_name,
220            status: user.status,
221            email_verified: user.email_verified,
222            roles: user.roles,
223            is_bot: user.is_bot,
224            is_scanner: user.is_scanner,
225            created_at: user.created_at,
226            updated_at: user.updated_at,
227        }
228    }
229}