1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
//! API request/response models for users.
use super::pagination::Pagination;
use crate::api::models::groups::GroupResponse;
use crate::db::models::users::UserDBResponse;
use crate::types::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_with::rust::double_option;
use utoipa::{IntoParams, ToSchema};
/// User role determining access permissions and capabilities.
///
/// Roles are additive - a user can have multiple roles, and their effective
/// permissions are the union of all role permissions.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type, PartialEq, ToSchema)]
#[sqlx(type_name = "user_role", rename_all = "UPPERCASE")]
pub enum Role {
/// Full administrative access: manage users, groups, deployments, and endpoints
PlatformManager,
/// Read-only access to API request logs and analytics
RequestViewer,
/// Basic user access: can make API requests through assigned model deployments
StandardUser,
/// Access to billing information and credit management
BillingManager,
/// Access to batch processing API endpoints
BatchAPIUser,
/// Access to external data source connections and sync operations
ConnectionsUser,
}
/// Request body for creating a new user.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserCreate {
/// Unique username for login (must be unique across the system)
#[schema(example = "jsmith")]
pub username: String,
/// User's email address (must be unique across the system)
#[schema(example = "john.smith@example.com")]
pub email: String,
/// Human-readable display name shown in the UI
#[schema(example = "John Smith")]
pub display_name: Option<String>,
/// URL to the user's avatar image
#[schema(example = "https://example.com/avatars/jsmith.png")]
pub avatar_url: Option<String>,
/// Roles to assign to this user (determines permissions)
pub roles: Vec<Role>,
}
/// Request body for updating an existing user. All fields are optional;
/// only provided fields will be updated.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserUpdate {
/// New display name (null to keep unchanged)
#[schema(example = "John Smith Jr.")]
pub display_name: Option<String>,
/// New avatar URL (null to keep unchanged)
#[schema(example = "https://example.com/avatars/jsmith-new.png")]
pub avatar_url: Option<String>,
/// New set of roles (replaces all existing roles; null to keep unchanged)
pub roles: Option<Vec<Role>>,
/// Whether to receive email notifications when batches complete (null to keep unchanged)
pub batch_notifications_enabled: Option<bool>,
/// Low balance notification threshold in dollars. Set to a number to enable
/// (e.g. 2.0 means notify when balance drops below $2), set to null to disable.
/// Omit entirely to leave unchanged.
#[serde(default, skip_serializing_if = "Option::is_none", with = "double_option")]
pub low_balance_threshold: Option<Option<f32>>,
/// Auto top-up amount in dollars. Set to a number to enable automatic credit
/// replenishment, set to null to disable. Omit entirely to leave unchanged.
#[serde(default, skip_serializing_if = "Option::is_none", with = "double_option")]
pub auto_topup_amount: Option<Option<f32>>,
/// Auto top-up threshold in dollars. When balance drops below this amount,
/// auto top-up is triggered. Set to null to disable. Omit entirely to leave unchanged.
#[serde(default, skip_serializing_if = "Option::is_none", with = "double_option")]
pub auto_topup_threshold: Option<Option<f32>>,
/// Monthly auto top-up spending limit in dollars. Set to a number to cap monthly
/// auto top-up charges, set to null to remove the limit. Omit entirely to leave unchanged.
#[serde(default, skip_serializing_if = "Option::is_none", with = "double_option")]
pub auto_topup_monthly_limit: Option<Option<f32>>,
}
/// Full user details returned by the API.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserResponse {
/// Unique identifier for the user
#[schema(value_type = String, format = "uuid")]
pub id: UserId,
/// Unique username for login
pub username: String,
/// User's email address
pub email: String,
/// Human-readable display name
pub display_name: Option<String>,
/// URL to the user's avatar image
pub avatar_url: Option<String>,
/// Whether this user has legacy admin privileges (deprecated, use roles instead)
pub is_admin: bool,
/// Roles assigned to this user
pub roles: Vec<Role>,
/// When the user account was created
pub created_at: DateTime<Utc>,
/// When the user account was last modified
pub updated_at: DateTime<Utc>,
/// When the user last logged in (null if never logged in)
pub last_login: Option<DateTime<Utc>>,
/// Authentication source (e.g., "local", "google", "oidc")
pub auth_source: String,
/// ID from external authentication provider (if using SSO)
pub external_user_id: Option<String>,
/// Groups this user belongs to (only included if `include=groups` is specified)
/// Note: no_recursion is important! utoipa will panic at runtime, because it overflows the
/// stack trying to follow the relationship.
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(no_recursion)]
pub groups: Option<Vec<GroupResponse>>,
/// User's credit balance (only included if `include=billing` is specified)
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_balance: Option<f64>,
/// Indicates whether this user has an associated payment provider customer record.
///
/// Note: This field replaces the previous `payment_provider_id` response field to avoid
/// exposing the underlying payment provider customer ID. API consumers that previously
/// relied on `payment_provider_id` should instead use this boolean flag and store or
/// manage any provider-specific identifiers on their own side.
pub has_payment_provider_id: bool,
/// Whether the user receives email notifications when batches complete
pub batch_notifications_enabled: bool,
/// Low balance notification threshold in dollars. Null means notifications are disabled.
pub low_balance_threshold: Option<f32>,
/// Auto top-up amount in dollars. Null means auto top-up is disabled.
pub auto_topup_amount: Option<f32>,
/// Auto top-up threshold in dollars. When balance drops below this, auto top-up triggers.
pub auto_topup_threshold: Option<f32>,
/// Whether the user has a payment method set up for auto top-up.
pub has_auto_topup_payment_method: bool,
/// Monthly auto top-up spending limit in dollars. Null means no limit.
pub auto_topup_monthly_limit: Option<f32>,
/// User type: 'individual' or 'organization'
pub user_type: String,
/// Organizations this user belongs to (only included if `include=organizations` is specified)
#[serde(skip_serializing_if = "Option::is_none")]
pub organizations: Option<Vec<super::organizations::OrganizationSummary>>,
/// Active organization ID from the session cookie (only present for /users/current)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<String>, format = "uuid")]
pub active_organization_id: Option<UserId>,
/// Onboarding redirect URL (only present for /users/current when last_login is null and onboarding_url is configured)
#[serde(skip_serializing_if = "Option::is_none")]
pub onboarding_redirect_url: Option<String>,
}
/// Query parameters for listing users
#[derive(Debug, Deserialize, IntoParams, ToSchema)]
pub struct ListUsersQuery {
/// Pagination parameters
#[serde(flatten)]
#[param(inline)]
pub pagination: Pagination,
/// Include related data (comma-separated: "groups", "billing")
pub include: Option<String>,
/// Search query to filter users by display_name, username, or email (case-insensitive substring match)
pub search: Option<String>,
}
/// The currently authenticated user's information.
/// This is a subset of UserResponse containing only the fields relevant
/// to the current session.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CurrentUser {
/// Unique identifier for the user
#[schema(value_type = String, format = "uuid")]
pub id: UserId,
/// Unique username for login
pub username: String,
/// User's email address
pub email: String,
/// Whether this user has legacy admin privileges
pub is_admin: bool,
/// Roles assigned to this user
pub roles: Vec<Role>,
/// Human-readable display name
pub display_name: Option<String>,
/// URL to the user's avatar image
pub avatar_url: Option<String>,
/// ID in external payment provider
pub payment_provider_id: Option<String>,
/// Organizations the user belongs to
pub organizations: Vec<UserOrganizationContext>,
/// Active organization ID (from X-Organization-Id header)
#[schema(value_type = Option<String>, format = "uuid")]
pub active_organization: Option<UserId>,
}
/// Context about a user's organization membership
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserOrganizationContext {
#[schema(value_type = String, format = "uuid")]
pub id: UserId,
pub name: String,
pub role: String,
}
impl CurrentUser {
#[cfg(test)]
pub fn is_admin(&self) -> bool {
self.is_admin
}
}
impl From<UserDBResponse> for UserResponse {
fn from(db: UserDBResponse) -> Self {
Self {
id: db.id,
username: db.username,
email: db.email,
display_name: db.display_name,
avatar_url: db.avatar_url,
is_admin: db.is_admin,
roles: db.roles,
created_at: db.created_at,
updated_at: db.updated_at,
auth_source: db.auth_source,
external_user_id: db.external_user_id,
last_login: db.last_login,
groups: None, // By default, relationships are not included
credit_balance: None, // By default, credit balances are not included
has_payment_provider_id: db.payment_provider_id.as_ref().is_some_and(|s| !s.is_empty()),
batch_notifications_enabled: db.batch_notifications_enabled,
low_balance_threshold: db.low_balance_threshold,
auto_topup_amount: db.auto_topup_amount,
auto_topup_threshold: db.auto_topup_threshold,
has_auto_topup_payment_method: db.payment_provider_id.as_ref().is_some_and(|s| !s.is_empty()),
auto_topup_monthly_limit: db.auto_topup_monthly_limit,
user_type: db.user_type,
organizations: None,
active_organization_id: None,
onboarding_redirect_url: None,
}
}
}
impl UserResponse {
/// Create a response with groups included
pub fn with_groups(mut self, groups: Vec<GroupResponse>) -> Self {
self.groups = Some(groups);
self
}
/// Create a response with credit balance included
pub fn with_credit_balance(mut self, balance: f64) -> Self {
self.credit_balance = Some(balance);
self
}
/// Create a response with organizations included
pub fn with_organizations(mut self, organizations: Vec<super::organizations::OrganizationSummary>) -> Self {
self.organizations = Some(organizations);
self
}
/// Set the active organization ID (from session cookie)
pub fn with_active_organization(mut self, id: Option<UserId>) -> Self {
self.active_organization_id = id;
self
}
/// Set the onboarding redirect URL (for first-time users)
pub fn with_onboarding_redirect_url(mut self, url: String) -> Self {
self.onboarding_redirect_url = Some(url);
self
}
}
impl From<UserDBResponse> for CurrentUser {
fn from(db: UserDBResponse) -> Self {
Self {
id: db.id,
username: db.username,
email: db.email,
is_admin: db.is_admin,
roles: db.roles,
display_name: db.display_name,
avatar_url: db.avatar_url,
payment_provider_id: db.payment_provider_id,
organizations: vec![],
active_organization: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct GetUserQuery {
/// Include related data (comma-separated: "groups", "billing")
#[schema(example = "groups,billing")]
pub include: Option<String>,
}