Skip to main content

opensession_api_types/
service.rs

1//! Shared business logic — framework-agnostic pure functions.
2//!
3//! Both the Axum server and Cloudflare Worker call these functions,
4//! keeping route handlers as thin adapters.
5
6use crate::{db, AuthTokenResponse, ServiceError, SessionListQuery};
7
8// ─── Validation ─────────────────────────────────────────────────────────────
9
10/// Validate and normalize an email address. Returns the lowercased, trimmed email.
11pub 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
19/// Validate a password (8-12 characters).
20pub 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
34/// Validate and normalize a user nickname. Returns the trimmed nickname.
35pub 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
45// ─── API Key Generation ─────────────────────────────────────────────────────
46
47/// Generate a new API key with the `osk_` prefix.
48pub fn generate_api_key() -> String {
49    format!("osk_{}", uuid::Uuid::new_v4().simple())
50}
51
52/// Validate and normalize a team name. Returns the trimmed name.
53pub 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
63// ─── Token Bundle ───────────────────────────────────────────────────────────
64
65/// Pre-computed token bundle returned by [`prepare_token_bundle`].
66///
67/// Contains everything needed to insert a refresh token and return the auth
68/// response. The caller only needs to perform the DB INSERT.
69pub struct TokenBundle {
70    /// JWT access token.
71    pub access_token: String,
72    /// Raw refresh token (sent to the client).
73    pub refresh_token: String,
74    /// SHA-256 hash of the refresh token (stored in DB).
75    pub token_hash: String,
76    /// UUID primary key for the refresh_tokens row.
77    pub token_id: String,
78    /// `datetime` string for the refresh token expiry (DB column value).
79    pub expires_at: String,
80    /// Ready-to-return API response.
81    pub response: AuthTokenResponse,
82}
83
84/// Build a [`TokenBundle`] containing a JWT, refresh token, and the auth response.
85///
86/// This is the pure-computation part of `issue_tokens`. Each backend only needs
87/// to insert the refresh token row into its database.
88pub 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// ─── Session List Query Builder ─────────────────────────────────────────────
129
130/// Abstract query parameter value, framework-agnostic.
131///
132/// Each backend converts this into its native bind type:
133/// - Axum/rusqlite: `Box<dyn ToSql>`
134/// - Worker/D1: `JsValue`
135#[derive(Debug, Clone)]
136pub enum ParamValue {
137    Text(String),
138    Int(i64),
139}
140
141/// Result of building a session list query from [`SessionListQuery`].
142pub struct BuiltSessionListQuery {
143    /// SQL for counting total results: `SELECT COUNT(*) as count FROM sessions s WHERE {where_clause}`
144    pub count_sql: String,
145    /// SQL for fetching a page: `SELECT ... FROM sessions s LEFT JOIN users u ... WHERE ... ORDER BY ... LIMIT ?N OFFSET ?N+1`
146    pub select_sql: String,
147    /// Bind params for the count query.
148    pub count_params: Vec<ParamValue>,
149    /// Bind params for the select query (count_params + LIMIT + OFFSET).
150    pub select_params: Vec<ParamValue>,
151    /// Current page number (from the query).
152    pub page: u32,
153    /// Clamped per_page value.
154    pub per_page: u32,
155}
156
157/// Build framework-agnostic SQL + params for paginated session listing.
158///
159/// The generated SQL uses the shared `SESSION_COLUMNS` and numbered `?N` bind
160/// parameters compatible with both SQLite and D1.
161pub 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); // LIMIT + OFFSET
286        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        // 1 tool + 1 team_id + 3 search + 1 time_range = 6 count params
308        assert_eq!(built.count_params.len(), 6);
309        // + 2 for LIMIT/OFFSET
310        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}