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/// Canonical email form used for every insert and lookup: trimmed and
39/// lowercased, so `Ed@x.com` and `ed@x.com` resolve to one account.
40#[must_use]
41pub fn normalise_email(email: &str) -> String {
42    email.trim().to_lowercase()
43}
44
45impl User {
46    pub fn is_active(&self) -> bool {
47        self.status.as_deref() == Some(UserStatus::Active.as_str())
48    }
49
50    pub fn is_admin(&self) -> bool {
51        self.roles.contains(&UserRole::Admin.as_str().to_owned())
52    }
53
54    pub fn has_role(&self, role: UserRole) -> bool {
55        self.roles.contains(&role.as_str().to_owned())
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
60pub struct UserActivity {
61    #[sqlx(try_from = "String")]
62    pub user_id: UserId,
63    pub last_active: Option<DateTime<Utc>>,
64    pub session_count: i64,
65    pub task_count: i64,
66    pub message_count: i64,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
70pub struct UserWithSessions {
71    #[sqlx(try_from = "String")]
72    pub id: UserId,
73    pub name: String,
74    pub email: String,
75    pub full_name: Option<String>,
76    pub status: Option<String>,
77    pub roles: Vec<String>,
78    pub created_at: Option<DateTime<Utc>>,
79    pub active_sessions: i64,
80    pub last_session_at: Option<DateTime<Utc>>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct UserSession {
85    pub session_id: SessionId,
86    pub user_id: Option<UserId>,
87    pub ip_address: Option<String>,
88    pub user_agent: Option<String>,
89    pub device_type: Option<String>,
90    pub started_at: Option<DateTime<Utc>>,
91    pub last_activity_at: Option<DateTime<Utc>>,
92    pub ended_at: Option<DateTime<Utc>>,
93}
94
95#[derive(Debug, Clone, FromRow)]
96pub struct UserSessionRow {
97    #[sqlx(try_from = "String")]
98    pub session_id: SessionId,
99    pub user_id: Option<UserId>,
100    pub ip_address: Option<String>,
101    pub user_agent: Option<String>,
102    pub device_type: Option<String>,
103    pub started_at: Option<DateTime<Utc>>,
104    pub last_activity_at: Option<DateTime<Utc>>,
105    pub ended_at: Option<DateTime<Utc>>,
106}
107
108impl From<UserSessionRow> for UserSession {
109    fn from(row: UserSessionRow) -> Self {
110        Self {
111            session_id: row.session_id,
112            user_id: row.user_id,
113            ip_address: row.ip_address,
114            user_agent: row.user_agent,
115            device_type: row.device_type,
116            started_at: row.started_at,
117            last_activity_at: row.last_activity_at,
118            ended_at: row.ended_at,
119        }
120    }
121}
122
123#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
124pub struct UserStats {
125    pub total: i64,
126    pub created_24h: i64,
127    pub created_7d: i64,
128    pub created_30d: i64,
129    pub active: i64,
130    pub suspended: i64,
131    pub admins: i64,
132    pub anonymous: i64,
133    pub bots: i64,
134    pub oldest_user: Option<DateTime<Utc>>,
135    pub newest_user: Option<DateTime<Utc>>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct UserCountBreakdown {
140    pub total: i64,
141    pub by_status: std::collections::HashMap<String, i64>,
142    pub by_role: std::collections::HashMap<String, i64>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct UserExport {
147    pub id: UserId,
148    pub name: String,
149    pub email: String,
150    pub full_name: Option<String>,
151    pub display_name: Option<String>,
152    pub status: Option<String>,
153    pub email_verified: Option<bool>,
154    pub roles: Vec<String>,
155    pub is_bot: bool,
156    pub is_scanner: bool,
157    pub created_at: Option<DateTime<Utc>>,
158    pub updated_at: Option<DateTime<Utc>>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
162pub struct UserApiKey {
163    #[sqlx(try_from = "String")]
164    pub id: ApiKeyId,
165    #[sqlx(try_from = "String")]
166    pub user_id: UserId,
167    pub name: String,
168    pub key_prefix: String,
169    pub key_hash: String,
170    pub created_at: Option<DateTime<Utc>>,
171    pub last_used_at: Option<DateTime<Utc>>,
172    pub expires_at: Option<DateTime<Utc>>,
173    pub revoked_at: Option<DateTime<Utc>>,
174}
175
176impl UserApiKey {
177    pub fn is_active(&self, now: DateTime<Utc>) -> bool {
178        if self.revoked_at.is_some() {
179            return false;
180        }
181        if let Some(expires_at) = self.expires_at
182            && now >= expires_at
183        {
184            return false;
185        }
186        true
187    }
188}
189
190#[derive(Debug, Clone)]
191pub struct NewApiKey {
192    pub record: UserApiKey,
193    pub secret: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
197pub struct UserDeviceCert {
198    #[sqlx(try_from = "String")]
199    pub id: DeviceCertId,
200    #[sqlx(try_from = "String")]
201    pub user_id: UserId,
202    pub fingerprint: String,
203    pub label: String,
204    pub enrolled_at: Option<DateTime<Utc>>,
205    pub revoked_at: Option<DateTime<Utc>>,
206}
207
208impl UserDeviceCert {
209    pub const fn is_active(&self) -> bool {
210        self.revoked_at.is_none()
211    }
212}
213
214impl From<User> for UserExport {
215    fn from(user: User) -> Self {
216        Self {
217            id: user.id,
218            name: user.name,
219            email: user.email,
220            full_name: user.full_name,
221            display_name: user.display_name,
222            status: user.status,
223            email_verified: user.email_verified,
224            roles: user.roles,
225            is_bot: user.is_bot,
226            is_scanner: user.is_scanner,
227            created_at: user.created_at,
228            updated_at: user.updated_at,
229        }
230    }
231}