1use crate::{db, AuthTokenResponse, ServiceError, SessionListQuery};
7
8pub fn validate_email(email: &str) -> Result<String, ServiceError> {
12 let email = email.trim().to_lowercase();
13 if email.is_empty() || !email.contains('@') || email.len() > 254 {
14 return Err(ServiceError::BadRequest("invalid email address".into()));
15 }
16 Ok(email)
17}
18
19pub fn validate_password(password: &str) -> Result<(), ServiceError> {
21 if password.len() < 8 {
22 return Err(ServiceError::BadRequest(
23 "password must be at least 8 characters".into(),
24 ));
25 }
26 if password.len() > 12 {
27 return Err(ServiceError::BadRequest(
28 "password must be at most 12 characters".into(),
29 ));
30 }
31 Ok(())
32}
33
34pub fn validate_nickname(nickname: &str) -> Result<String, ServiceError> {
36 let trimmed = nickname.trim().to_string();
37 if trimmed.is_empty() || trimmed.len() > 64 {
38 return Err(ServiceError::BadRequest(
39 "nickname must be 1-64 characters".into(),
40 ));
41 }
42 Ok(trimmed)
43}
44
45pub fn generate_api_key() -> String {
49 format!("osk_{}", uuid::Uuid::new_v4().simple())
50}
51
52pub fn validate_team_name(name: &str) -> Result<String, ServiceError> {
54 let trimmed = name.trim().to_string();
55 if trimmed.is_empty() || trimmed.len() > 128 {
56 return Err(ServiceError::BadRequest(
57 "name must be 1-128 characters".into(),
58 ));
59 }
60 Ok(trimmed)
61}
62
63pub struct TokenBundle {
70 pub access_token: String,
72 pub refresh_token: String,
74 pub token_hash: String,
76 pub token_id: String,
78 pub expires_at: String,
80 pub response: AuthTokenResponse,
82}
83
84pub fn prepare_token_bundle(
89 jwt_secret: &str,
90 user_id: &str,
91 nickname: &str,
92 now_unix: u64,
93) -> TokenBundle {
94 use crate::crypto;
95
96 let access_token = crypto::sign_jwt(user_id, jwt_secret, now_unix);
97 let refresh_token = crypto::generate_token();
98 let token_hash = crypto::hash_token(&refresh_token);
99 let token_id = uuid::Uuid::new_v4().to_string();
100
101 let expires_at = chrono::DateTime::from_timestamp(now_unix as i64, 0)
102 .unwrap_or_default()
103 .checked_add_signed(chrono::Duration::seconds(
104 crypto::REFRESH_EXPIRY_SECS as i64,
105 ))
106 .unwrap_or_default()
107 .format("%Y-%m-%d %H:%M:%S")
108 .to_string();
109
110 let response = AuthTokenResponse {
111 access_token: access_token.clone(),
112 refresh_token: refresh_token.clone(),
113 expires_in: crypto::JWT_EXPIRY_SECS,
114 user_id: user_id.to_string(),
115 nickname: nickname.to_string(),
116 };
117
118 TokenBundle {
119 access_token,
120 refresh_token,
121 token_hash,
122 token_id,
123 expires_at,
124 response,
125 }
126}
127
128#[derive(Debug, Clone)]
136pub enum ParamValue {
137 Text(String),
138 Int(i64),
139}
140
141pub struct BuiltSessionListQuery {
143 pub count_sql: String,
145 pub select_sql: String,
147 pub count_params: Vec<ParamValue>,
149 pub select_params: Vec<ParamValue>,
151 pub page: u32,
153 pub per_page: u32,
155}
156
157pub fn build_session_list_query(q: &SessionListQuery) -> BuiltSessionListQuery {
162 let per_page = q.per_page.clamp(1, 100);
163 let offset = (q.page.saturating_sub(1)) * per_page;
164
165 let mut where_parts = vec!["(s.event_count > 0 OR s.message_count > 0)".to_string()];
166 let mut params: Vec<ParamValue> = Vec::new();
167 let mut idx = 1u32;
168
169 if let Some(ref tool) = q.tool {
170 where_parts.push(format!("s.tool = ?{idx}"));
171 params.push(ParamValue::Text(tool.clone()));
172 idx += 1;
173 }
174
175 if let Some(ref team_id) = q.team_id {
176 where_parts.push(format!("s.team_id = ?{idx}"));
177 params.push(ParamValue::Text(team_id.clone()));
178 idx += 1;
179 }
180
181 if let Some(ref search) = q.search {
182 let like = format!("%{search}%");
183 where_parts.push(format!(
184 "(s.title LIKE ?{p1} OR s.description LIKE ?{p2} OR s.tags LIKE ?{p3})",
185 p1 = idx,
186 p2 = idx + 1,
187 p3 = idx + 2,
188 ));
189 params.push(ParamValue::Text(like.clone()));
190 params.push(ParamValue::Text(like.clone()));
191 params.push(ParamValue::Text(like));
192 idx += 3;
193 }
194
195 if let Some(ref time_range) = q.time_range {
196 let interval = match time_range.as_str() {
197 "24h" => Some("-1 day"),
198 "7d" => Some("-7 days"),
199 "30d" => Some("-30 days"),
200 _ => None,
201 };
202 if let Some(interval) = interval {
203 where_parts.push(format!("s.created_at >= datetime('now', ?{idx})"));
204 params.push(ParamValue::Text(interval.to_string()));
205 idx += 1;
206 }
207 }
208
209 let where_clause = where_parts.join(" AND ");
210
211 let order_clause = match q.sort.as_deref() {
212 Some("popular") => "s.message_count DESC, s.created_at DESC",
213 Some("longest") => "s.duration_seconds DESC, s.created_at DESC",
214 _ => "s.created_at DESC",
215 };
216
217 let count_sql = format!("SELECT COUNT(*) as count FROM sessions s WHERE {where_clause}");
218
219 let select_sql = format!(
220 "SELECT {} \
221 FROM sessions s \
222 LEFT JOIN users u ON u.id = s.user_id \
223 WHERE {where_clause} \
224 ORDER BY {order_clause} \
225 LIMIT ?{idx} OFFSET ?{}",
226 db::SESSION_COLUMNS,
227 idx + 1,
228 );
229
230 let mut select_params = params.clone();
231 select_params.push(ParamValue::Int(per_page as i64));
232 select_params.push(ParamValue::Int(offset as i64));
233
234 BuiltSessionListQuery {
235 count_sql,
236 select_sql,
237 count_params: params,
238 select_params,
239 page: q.page,
240 per_page,
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn test_validate_nickname() {
250 assert!(validate_nickname("alice").is_ok());
251 assert_eq!(validate_nickname(" bob ").unwrap(), "bob");
252 assert!(validate_nickname("").is_err());
253 assert!(validate_nickname(" ").is_err());
254 assert!(validate_nickname(&"x".repeat(65)).is_err());
255 assert!(validate_nickname(&"x".repeat(64)).is_ok());
256 }
257
258 #[test]
259 fn test_validate_team_name() {
260 assert!(validate_team_name("my-team").is_ok());
261 assert_eq!(validate_team_name(" team ").unwrap(), "team");
262 assert!(validate_team_name("").is_err());
263 assert!(validate_team_name(" ").is_err());
264 assert!(validate_team_name(&"x".repeat(129)).is_err());
265 assert!(validate_team_name(&"x".repeat(128)).is_ok());
266 }
267
268 #[test]
269 fn test_build_session_list_query_defaults() {
270 let q = SessionListQuery {
271 page: 1,
272 per_page: 20,
273 search: None,
274 tool: None,
275 team_id: None,
276 sort: None,
277 time_range: None,
278 };
279 let built = build_session_list_query(&q);
280 assert!(built
281 .count_sql
282 .contains("WHERE (s.event_count > 0 OR s.message_count > 0)"));
283 assert!(built.select_sql.contains("ORDER BY s.created_at DESC"));
284 assert!(built.count_params.is_empty());
285 assert_eq!(built.select_params.len(), 2); assert_eq!(built.page, 1);
287 assert_eq!(built.per_page, 20);
288 }
289
290 #[test]
291 fn test_build_session_list_query_with_filters() {
292 let q = SessionListQuery {
293 page: 2,
294 per_page: 10,
295 search: Some("rust".into()),
296 tool: Some("claude-code".into()),
297 team_id: Some("team-1".into()),
298 sort: Some("popular".into()),
299 time_range: Some("7d".into()),
300 };
301 let built = build_session_list_query(&q);
302 assert!(built.count_sql.contains("s.tool = ?1"));
303 assert!(built.count_sql.contains("s.team_id = ?2"));
304 assert!(built.count_sql.contains("s.title LIKE ?3"));
305 assert!(built.count_sql.contains("datetime('now', ?6)"));
306 assert!(built.select_sql.contains("ORDER BY s.message_count DESC"));
307 assert_eq!(built.count_params.len(), 6);
309 assert_eq!(built.select_params.len(), 8);
311 assert_eq!(built.per_page, 10);
312 }
313
314 #[test]
315 fn test_build_session_list_query_clamps_per_page() {
316 let q = SessionListQuery {
317 page: 1,
318 per_page: 500,
319 search: None,
320 tool: None,
321 team_id: None,
322 sort: None,
323 time_range: None,
324 };
325 let built = build_session_list_query(&q);
326 assert_eq!(built.per_page, 100);
327 }
328}