Skip to main content

opensession_api/
lib.rs

1//! Shared API types, crypto, and SQL builders for opensession.io
2//!
3//! This crate is the **single source of truth** for all API request/response types.
4//! TypeScript types are auto-generated via `ts-rs` and consumed by the frontend.
5//!
6//! To regenerate TypeScript types:
7//!   cargo test -p opensession-api -- export_typescript --nocapture
8
9use serde::{Deserialize, Serialize};
10
11#[cfg(feature = "backend")]
12pub mod crypto;
13#[cfg(feature = "backend")]
14pub mod db;
15pub mod oauth;
16#[cfg(feature = "backend")]
17pub mod service;
18
19// Re-export core HAIL types for convenience
20pub use opensession_core::trace::{
21    Agent, Content, ContentBlock, Event, EventType, Session, SessionContext, Stats,
22};
23
24// ─── Shared Enums ────────────────────────────────────────────────────────────
25
26/// Role within a team.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
30#[cfg_attr(feature = "ts", ts(export))]
31pub enum TeamRole {
32    Admin,
33    Member,
34}
35
36impl TeamRole {
37    pub fn as_str(&self) -> &str {
38        match self {
39            Self::Admin => "admin",
40            Self::Member => "member",
41        }
42    }
43}
44
45impl std::fmt::Display for TeamRole {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.write_str(self.as_str())
48    }
49}
50
51/// Status of a team invitation.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "snake_case")]
54#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
55#[cfg_attr(feature = "ts", ts(export))]
56pub enum InvitationStatus {
57    Pending,
58    Accepted,
59    Declined,
60}
61
62impl InvitationStatus {
63    pub fn as_str(&self) -> &str {
64        match self {
65            Self::Pending => "pending",
66            Self::Accepted => "accepted",
67            Self::Declined => "declined",
68        }
69    }
70}
71
72impl std::fmt::Display for InvitationStatus {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str(self.as_str())
75    }
76}
77
78/// Sort order for session listings.
79#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
80#[serde(rename_all = "snake_case")]
81#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
82#[cfg_attr(feature = "ts", ts(export))]
83pub enum SortOrder {
84    #[default]
85    Recent,
86    Popular,
87    Longest,
88}
89
90impl SortOrder {
91    pub fn as_str(&self) -> &str {
92        match self {
93            Self::Recent => "recent",
94            Self::Popular => "popular",
95            Self::Longest => "longest",
96        }
97    }
98}
99
100impl std::fmt::Display for SortOrder {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(self.as_str())
103    }
104}
105
106/// Time range filter for queries.
107#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
108#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
109#[cfg_attr(feature = "ts", ts(export))]
110pub enum TimeRange {
111    #[serde(rename = "24h")]
112    Hours24,
113    #[serde(rename = "7d")]
114    Days7,
115    #[serde(rename = "30d")]
116    Days30,
117    #[default]
118    #[serde(rename = "all")]
119    All,
120}
121
122impl TimeRange {
123    pub fn as_str(&self) -> &str {
124        match self {
125            Self::Hours24 => "24h",
126            Self::Days7 => "7d",
127            Self::Days30 => "30d",
128            Self::All => "all",
129        }
130    }
131}
132
133impl std::fmt::Display for TimeRange {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.write_str(self.as_str())
136    }
137}
138
139/// Type of link between two sessions.
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "snake_case")]
142#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
143#[cfg_attr(feature = "ts", ts(export))]
144pub enum LinkType {
145    Handoff,
146    Related,
147    Parent,
148    Child,
149}
150
151impl LinkType {
152    pub fn as_str(&self) -> &str {
153        match self {
154            Self::Handoff => "handoff",
155            Self::Related => "related",
156            Self::Parent => "parent",
157            Self::Child => "child",
158        }
159    }
160}
161
162impl std::fmt::Display for LinkType {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.write_str(self.as_str())
165    }
166}
167
168// ─── Utilities ───────────────────────────────────────────────────────────────
169
170/// Safely convert `u64` to `i64`, saturating at `i64::MAX` instead of wrapping.
171pub fn saturating_i64(v: u64) -> i64 {
172    i64::try_from(v).unwrap_or(i64::MAX)
173}
174
175// ─── Auth ────────────────────────────────────────────────────────────────────
176
177/// Legacy register (nickname-only). Kept for backward compatibility with CLI.
178#[derive(Debug, Deserialize)]
179#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
180#[cfg_attr(feature = "ts", ts(export))]
181pub struct RegisterRequest {
182    pub nickname: String,
183}
184
185/// Email + password registration.
186#[derive(Debug, Serialize, Deserialize)]
187#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
188#[cfg_attr(feature = "ts", ts(export))]
189pub struct AuthRegisterRequest {
190    pub email: String,
191    pub password: String,
192    pub nickname: String,
193}
194
195/// Email + password login.
196#[derive(Debug, Serialize, Deserialize)]
197#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
198#[cfg_attr(feature = "ts", ts(export))]
199pub struct LoginRequest {
200    pub email: String,
201    pub password: String,
202}
203
204/// Returned on successful login / register / refresh.
205#[derive(Debug, Serialize, Deserialize)]
206#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
207#[cfg_attr(feature = "ts", ts(export))]
208pub struct AuthTokenResponse {
209    pub access_token: String,
210    pub refresh_token: String,
211    pub expires_in: u64,
212    pub user_id: String,
213    pub nickname: String,
214}
215
216/// Refresh token request.
217#[derive(Debug, Serialize, Deserialize)]
218#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
219#[cfg_attr(feature = "ts", ts(export))]
220pub struct RefreshRequest {
221    pub refresh_token: String,
222}
223
224/// Logout request (invalidate refresh token).
225#[derive(Debug, Serialize, Deserialize)]
226#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
227#[cfg_attr(feature = "ts", ts(export))]
228pub struct LogoutRequest {
229    pub refresh_token: String,
230}
231
232/// Change password request.
233#[derive(Debug, Serialize, Deserialize)]
234#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
235#[cfg_attr(feature = "ts", ts(export))]
236pub struct ChangePasswordRequest {
237    pub current_password: String,
238    pub new_password: String,
239}
240
241/// Returned on successful legacy register (nickname-only, CLI-compatible).
242#[derive(Debug, Serialize)]
243#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
244#[cfg_attr(feature = "ts", ts(export))]
245pub struct RegisterResponse {
246    pub user_id: String,
247    pub nickname: String,
248    pub api_key: String,
249}
250
251/// Returned by `POST /api/auth/verify` — confirms token validity.
252#[derive(Debug, Serialize, Deserialize)]
253#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
254#[cfg_attr(feature = "ts", ts(export))]
255pub struct VerifyResponse {
256    pub user_id: String,
257    pub nickname: String,
258}
259
260/// Full user profile returned by `GET /api/auth/me`.
261#[derive(Debug, Serialize, Deserialize)]
262#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
263#[cfg_attr(feature = "ts", ts(export))]
264pub struct UserSettingsResponse {
265    pub user_id: String,
266    pub nickname: String,
267    pub api_key: String,
268    pub created_at: String,
269    pub email: Option<String>,
270    pub avatar_url: Option<String>,
271    /// Linked OAuth providers (generic — replaces github_username)
272    #[serde(default)]
273    pub oauth_providers: Vec<oauth::LinkedProvider>,
274    /// Legacy: GitHub username (populated from oauth_providers for backward compat)
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub github_username: Option<String>,
277}
278
279/// Generic success response for operations that don't return data.
280#[derive(Debug, Serialize, Deserialize)]
281#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
282#[cfg_attr(feature = "ts", ts(export))]
283pub struct OkResponse {
284    pub ok: bool,
285}
286
287/// Response for API key regeneration.
288#[derive(Debug, Serialize, Deserialize)]
289#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
290#[cfg_attr(feature = "ts", ts(export))]
291pub struct RegenerateKeyResponse {
292    pub api_key: String,
293}
294
295/// Response for OAuth link initiation (redirect URL).
296#[derive(Debug, Serialize)]
297#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
298#[cfg_attr(feature = "ts", ts(export))]
299pub struct OAuthLinkResponse {
300    pub url: String,
301}
302
303// ─── Sessions ────────────────────────────────────────────────────────────────
304
305/// Request body for `POST /api/sessions` — upload a recorded session.
306#[derive(Debug, Serialize, Deserialize)]
307pub struct UploadRequest {
308    pub session: Session,
309    pub team_id: Option<String>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub body_url: Option<String>,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub linked_session_ids: Option<Vec<String>>,
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub git_remote: Option<String>,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub git_branch: Option<String>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub git_commit: Option<String>,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub git_repo_name: Option<String>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub pr_number: Option<i64>,
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub pr_url: Option<String>,
326}
327
328/// Returned on successful session upload — contains the new session ID and URL.
329#[derive(Debug, Serialize, Deserialize)]
330#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
331#[cfg_attr(feature = "ts", ts(export))]
332pub struct UploadResponse {
333    pub id: String,
334    pub url: String,
335}
336
337/// Flat session summary returned by list/detail endpoints.
338/// This is NOT the full HAIL Session — it's a DB-derived summary.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
341#[cfg_attr(feature = "ts", ts(export))]
342pub struct SessionSummary {
343    pub id: String,
344    pub user_id: Option<String>,
345    pub nickname: Option<String>,
346    pub team_id: String,
347    pub tool: String,
348    pub agent_provider: Option<String>,
349    pub agent_model: Option<String>,
350    pub title: Option<String>,
351    pub description: Option<String>,
352    /// Comma-separated tags string
353    pub tags: Option<String>,
354    pub created_at: String,
355    pub uploaded_at: String,
356    pub message_count: i64,
357    pub task_count: i64,
358    pub event_count: i64,
359    pub duration_seconds: i64,
360    pub total_input_tokens: i64,
361    pub total_output_tokens: i64,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub git_remote: Option<String>,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub git_branch: Option<String>,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub git_commit: Option<String>,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub git_repo_name: Option<String>,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub pr_number: Option<i64>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub pr_url: Option<String>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub working_directory: Option<String>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub files_modified: Option<String>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub files_read: Option<String>,
380    #[serde(default)]
381    pub has_errors: bool,
382    #[serde(default = "default_max_active_agents")]
383    pub max_active_agents: i64,
384}
385
386/// Paginated session listing returned by `GET /api/sessions`.
387#[derive(Debug, Serialize, Deserialize)]
388#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
389#[cfg_attr(feature = "ts", ts(export))]
390pub struct SessionListResponse {
391    pub sessions: Vec<SessionSummary>,
392    pub total: i64,
393    pub page: u32,
394    pub per_page: u32,
395}
396
397/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting.
398#[derive(Debug, Deserialize)]
399#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
400#[cfg_attr(feature = "ts", ts(export))]
401pub struct SessionListQuery {
402    #[serde(default = "default_page")]
403    pub page: u32,
404    #[serde(default = "default_per_page")]
405    pub per_page: u32,
406    pub search: Option<String>,
407    pub tool: Option<String>,
408    pub team_id: Option<String>,
409    /// Sort order (default: recent)
410    pub sort: Option<SortOrder>,
411    /// Time range filter (default: all)
412    pub time_range: Option<TimeRange>,
413}
414
415impl SessionListQuery {
416    /// Returns true when this query targets the anonymous public feed and is safe to edge-cache.
417    pub fn is_public_feed_cacheable(
418        &self,
419        has_auth_header: bool,
420        has_session_cookie: bool,
421    ) -> bool {
422        !has_auth_header
423            && !has_session_cookie
424            && self.team_id.is_none()
425            && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
426            && self.page <= 10
427            && self.per_page <= 50
428    }
429}
430
431#[cfg(test)]
432mod session_list_query_tests {
433    use super::*;
434
435    fn base_query() -> SessionListQuery {
436        SessionListQuery {
437            page: 1,
438            per_page: 20,
439            search: None,
440            tool: None,
441            team_id: None,
442            sort: None,
443            time_range: None,
444        }
445    }
446
447    #[test]
448    fn public_feed_cacheable_when_anonymous_default_feed() {
449        let q = base_query();
450        assert!(q.is_public_feed_cacheable(false, false));
451    }
452
453    #[test]
454    fn public_feed_not_cacheable_with_auth_or_cookie() {
455        let q = base_query();
456        assert!(!q.is_public_feed_cacheable(true, false));
457        assert!(!q.is_public_feed_cacheable(false, true));
458    }
459
460    #[test]
461    fn public_feed_not_cacheable_for_team_or_search_or_large_page() {
462        let mut q = base_query();
463        q.team_id = Some("team-1".into());
464        assert!(!q.is_public_feed_cacheable(false, false));
465
466        let mut q = base_query();
467        q.search = Some("hello".into());
468        assert!(!q.is_public_feed_cacheable(false, false));
469
470        let mut q = base_query();
471        q.page = 11;
472        assert!(!q.is_public_feed_cacheable(false, false));
473
474        let mut q = base_query();
475        q.per_page = 100;
476        assert!(!q.is_public_feed_cacheable(false, false));
477    }
478}
479
480fn default_page() -> u32 {
481    1
482}
483fn default_per_page() -> u32 {
484    20
485}
486fn default_max_active_agents() -> i64 {
487    1
488}
489
490/// Single session detail returned by `GET /api/sessions/:id`.
491#[derive(Debug, Serialize, Deserialize)]
492#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
493#[cfg_attr(feature = "ts", ts(export))]
494pub struct SessionDetail {
495    #[serde(flatten)]
496    #[cfg_attr(feature = "ts", ts(flatten))]
497    pub summary: SessionSummary,
498    pub team_name: Option<String>,
499    #[serde(default, skip_serializing_if = "Vec::is_empty")]
500    pub linked_sessions: Vec<SessionLink>,
501}
502
503/// A link between two sessions (e.g., handoff chain).
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
506#[cfg_attr(feature = "ts", ts(export))]
507pub struct SessionLink {
508    pub session_id: String,
509    pub linked_session_id: String,
510    pub link_type: LinkType,
511    pub created_at: String,
512}
513
514// ─── Teams ──────────────────────────────────────────────────────────────────
515
516/// Request body for `POST /api/teams` — create a new team.
517#[derive(Debug, Serialize, Deserialize)]
518#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
519#[cfg_attr(feature = "ts", ts(export))]
520pub struct CreateTeamRequest {
521    pub name: String,
522    pub description: Option<String>,
523    pub is_public: Option<bool>,
524}
525
526/// Single team record returned by list and detail endpoints.
527#[derive(Debug, Serialize, Deserialize)]
528#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
529#[cfg_attr(feature = "ts", ts(export))]
530pub struct TeamResponse {
531    pub id: String,
532    pub name: String,
533    pub description: Option<String>,
534    pub is_public: bool,
535    pub created_by: String,
536    pub created_at: String,
537}
538
539/// Returned by `GET /api/teams` — teams the authenticated user belongs to.
540#[derive(Debug, Serialize, Deserialize)]
541#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
542#[cfg_attr(feature = "ts", ts(export))]
543pub struct ListTeamsResponse {
544    pub teams: Vec<TeamResponse>,
545}
546
547/// Returned by `GET /api/teams/:id` — team info with member count and recent sessions.
548#[derive(Debug, Serialize, Deserialize)]
549#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
550#[cfg_attr(feature = "ts", ts(export))]
551pub struct TeamDetailResponse {
552    #[serde(flatten)]
553    #[cfg_attr(feature = "ts", ts(flatten))]
554    pub team: TeamResponse,
555    pub member_count: i64,
556    pub sessions: Vec<SessionSummary>,
557}
558
559/// Request body for `PUT /api/teams/:id` — partial team update.
560#[derive(Debug, Serialize, Deserialize)]
561#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
562#[cfg_attr(feature = "ts", ts(export))]
563pub struct UpdateTeamRequest {
564    pub name: Option<String>,
565    pub description: Option<String>,
566    pub is_public: Option<bool>,
567}
568
569/// Query parameters for `GET /api/teams/:id/stats`.
570#[derive(Debug, Deserialize)]
571#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
572#[cfg_attr(feature = "ts", ts(export))]
573pub struct TeamStatsQuery {
574    /// Time range filter (default: all)
575    pub time_range: Option<TimeRange>,
576}
577
578/// Returned by `GET /api/teams/:id/stats` — aggregated team statistics.
579#[derive(Debug, Serialize)]
580#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
581#[cfg_attr(feature = "ts", ts(export))]
582pub struct TeamStatsResponse {
583    pub team_id: String,
584    pub time_range: TimeRange,
585    pub totals: TeamStatsTotals,
586    pub by_user: Vec<UserStats>,
587    pub by_tool: Vec<ToolStats>,
588}
589
590/// Aggregate totals across all sessions in a team.
591#[derive(Debug, Serialize)]
592#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
593#[cfg_attr(feature = "ts", ts(export))]
594pub struct TeamStatsTotals {
595    pub session_count: i64,
596    pub message_count: i64,
597    pub event_count: i64,
598    pub tool_call_count: i64,
599    pub duration_seconds: i64,
600    pub total_input_tokens: i64,
601    pub total_output_tokens: i64,
602}
603
604/// Per-user aggregated statistics within a team.
605#[derive(Debug, Serialize)]
606#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
607#[cfg_attr(feature = "ts", ts(export))]
608pub struct UserStats {
609    pub user_id: String,
610    pub nickname: String,
611    pub session_count: i64,
612    pub message_count: i64,
613    pub event_count: i64,
614    pub duration_seconds: i64,
615    pub total_input_tokens: i64,
616    pub total_output_tokens: i64,
617}
618
619/// Per-tool aggregated statistics within a team.
620#[derive(Debug, Serialize)]
621#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
622#[cfg_attr(feature = "ts", ts(export))]
623pub struct ToolStats {
624    pub tool: String,
625    pub session_count: i64,
626    pub message_count: i64,
627    pub event_count: i64,
628    pub duration_seconds: i64,
629    pub total_input_tokens: i64,
630    pub total_output_tokens: i64,
631}
632
633/// Request body for `POST /api/teams/:id/keys`.
634#[derive(Debug, Serialize, Deserialize)]
635#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
636#[cfg_attr(feature = "ts", ts(export))]
637pub struct CreateTeamInviteKeyRequest {
638    pub role: Option<TeamRole>,
639    /// Defaults to 7 days. Clamped to [1, 30].
640    pub expires_in_days: Option<u32>,
641}
642
643/// Create response for team invite key generation.
644/// `invite_key` is only returned once.
645#[derive(Debug, Serialize, Deserialize)]
646#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
647#[cfg_attr(feature = "ts", ts(export))]
648pub struct CreateTeamInviteKeyResponse {
649    pub key_id: String,
650    pub invite_key: String,
651    pub role: TeamRole,
652    pub expires_at: String,
653}
654
655/// Team invite key metadata, safe to list repeatedly.
656#[derive(Debug, Serialize, Deserialize)]
657#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
658#[cfg_attr(feature = "ts", ts(export))]
659pub struct TeamInviteKeySummary {
660    pub id: String,
661    pub role: TeamRole,
662    pub created_by_nickname: String,
663    pub created_at: String,
664    pub expires_at: String,
665    pub used_at: Option<String>,
666    pub revoked_at: Option<String>,
667}
668
669/// Returned by `GET /api/teams/:id/keys`.
670#[derive(Debug, Serialize, Deserialize)]
671#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
672#[cfg_attr(feature = "ts", ts(export))]
673pub struct ListTeamInviteKeysResponse {
674    pub keys: Vec<TeamInviteKeySummary>,
675}
676
677/// Request body for `POST /api/teams/join-with-key`.
678#[derive(Debug, Serialize, Deserialize)]
679#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
680#[cfg_attr(feature = "ts", ts(export))]
681pub struct JoinTeamWithKeyRequest {
682    pub invite_key: String,
683}
684
685/// Returned after successful key redemption.
686#[derive(Debug, Serialize, Deserialize)]
687#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
688#[cfg_attr(feature = "ts", ts(export))]
689pub struct JoinTeamWithKeyResponse {
690    pub team_id: String,
691    pub team_name: String,
692    pub role: TeamRole,
693}
694
695// ─── From impls: core stats → API types ─────────────────────────────────────
696
697impl From<opensession_core::stats::SessionAggregate> for TeamStatsTotals {
698    fn from(a: opensession_core::stats::SessionAggregate) -> Self {
699        Self {
700            session_count: saturating_i64(a.session_count),
701            message_count: saturating_i64(a.message_count),
702            event_count: saturating_i64(a.event_count),
703            tool_call_count: saturating_i64(a.tool_call_count),
704            duration_seconds: saturating_i64(a.duration_seconds),
705            total_input_tokens: saturating_i64(a.total_input_tokens),
706            total_output_tokens: saturating_i64(a.total_output_tokens),
707        }
708    }
709}
710
711impl From<(String, opensession_core::stats::SessionAggregate)> for ToolStats {
712    fn from((tool, a): (String, opensession_core::stats::SessionAggregate)) -> Self {
713        Self {
714            tool,
715            session_count: saturating_i64(a.session_count),
716            message_count: saturating_i64(a.message_count),
717            event_count: saturating_i64(a.event_count),
718            duration_seconds: saturating_i64(a.duration_seconds),
719            total_input_tokens: saturating_i64(a.total_input_tokens),
720            total_output_tokens: saturating_i64(a.total_output_tokens),
721        }
722    }
723}
724
725// ─── Invitations ─────────────────────────────────────────────────────────────
726
727/// Request body for `POST /api/teams/:id/invite` — invite a user by email or OAuth identity.
728#[derive(Debug, Serialize, Deserialize)]
729#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
730#[cfg_attr(feature = "ts", ts(export))]
731pub struct InviteRequest {
732    pub email: Option<String>,
733    /// OAuth provider name (e.g., "github", "gitlab").
734    pub oauth_provider: Option<String>,
735    /// Username on the OAuth provider (e.g., "octocat").
736    pub oauth_provider_username: Option<String>,
737    pub role: Option<TeamRole>,
738}
739
740/// Single invitation record returned by list and detail endpoints.
741#[derive(Debug, Serialize, Deserialize)]
742#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
743#[cfg_attr(feature = "ts", ts(export))]
744pub struct InvitationResponse {
745    pub id: String,
746    pub team_id: String,
747    pub team_name: String,
748    pub email: Option<String>,
749    pub oauth_provider: Option<String>,
750    pub oauth_provider_username: Option<String>,
751    pub invited_by_nickname: String,
752    pub role: TeamRole,
753    pub status: InvitationStatus,
754    pub created_at: String,
755}
756
757/// Returned by `GET /api/invitations` — pending invitations for the current user.
758#[derive(Debug, Serialize, Deserialize)]
759#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
760#[cfg_attr(feature = "ts", ts(export))]
761pub struct ListInvitationsResponse {
762    pub invitations: Vec<InvitationResponse>,
763}
764
765/// Returned by `POST /api/invitations/:id/accept` — confirms team join.
766#[derive(Debug, Serialize, Deserialize)]
767#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
768#[cfg_attr(feature = "ts", ts(export))]
769pub struct AcceptInvitationResponse {
770    pub team_id: String,
771    pub role: TeamRole,
772}
773
774// ─── Members (admin-managed) ────────────────────────────────────────────────
775
776/// Request body for `POST /api/teams/:id/members` — add a member by nickname.
777#[derive(Debug, Serialize, Deserialize)]
778#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
779#[cfg_attr(feature = "ts", ts(export))]
780pub struct AddMemberRequest {
781    pub nickname: String,
782}
783
784/// Single team member record.
785#[derive(Debug, Serialize, Deserialize)]
786#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
787#[cfg_attr(feature = "ts", ts(export))]
788pub struct MemberResponse {
789    pub user_id: String,
790    pub nickname: String,
791    pub role: TeamRole,
792    pub joined_at: String,
793}
794
795/// Returned by `GET /api/teams/:id/members`.
796#[derive(Debug, Serialize, Deserialize)]
797#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
798#[cfg_attr(feature = "ts", ts(export))]
799pub struct ListMembersResponse {
800    pub members: Vec<MemberResponse>,
801}
802
803// ─── Config Sync ─────────────────────────────────────────────────────────────
804
805/// Team-level configuration synced to CLI clients.
806#[derive(Debug, Serialize, Deserialize)]
807#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
808#[cfg_attr(feature = "ts", ts(export))]
809pub struct ConfigSyncResponse {
810    pub privacy: Option<SyncedPrivacyConfig>,
811    pub watchers: Option<SyncedWatcherConfig>,
812}
813
814/// Privacy settings synced from the team — controls what data is recorded.
815#[derive(Debug, Serialize, Deserialize)]
816#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
817#[cfg_attr(feature = "ts", ts(export))]
818pub struct SyncedPrivacyConfig {
819    pub exclude_patterns: Option<Vec<String>>,
820    pub exclude_tools: Option<Vec<String>>,
821}
822
823/// Watcher toggle settings synced from the team — which tools to monitor.
824#[derive(Debug, Serialize, Deserialize)]
825#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
826#[cfg_attr(feature = "ts", ts(export))]
827pub struct SyncedWatcherConfig {
828    pub claude_code: Option<bool>,
829    pub opencode: Option<bool>,
830    pub cursor: Option<bool>,
831}
832
833// ─── Sync ────────────────────────────────────────────────────────────────────
834
835/// Query parameters for `GET /api/sync/pull` — cursor-based session sync.
836#[derive(Debug, Deserialize)]
837pub struct SyncPullQuery {
838    pub team_id: String,
839    /// Cursor: uploaded_at of the last received session
840    pub since: Option<String>,
841    /// Max sessions per page (default 100)
842    pub limit: Option<u32>,
843}
844
845/// Returned by `GET /api/sync/pull` — paginated session data with cursor.
846#[derive(Debug, Serialize, Deserialize)]
847pub struct SyncPullResponse {
848    pub sessions: Vec<SessionSummary>,
849    /// Cursor for the next page (None = no more data)
850    pub next_cursor: Option<String>,
851    pub has_more: bool,
852}
853
854// ─── Streaming Events ────────────────────────────────────────────────────────
855
856/// Request body for `POST /api/sessions/:id/events` — append live events.
857#[derive(Debug, Deserialize)]
858#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
859#[cfg_attr(feature = "ts", ts(export))]
860pub struct StreamEventsRequest {
861    #[cfg_attr(feature = "ts", ts(type = "any"))]
862    pub agent: Option<Agent>,
863    #[cfg_attr(feature = "ts", ts(type = "any"))]
864    pub context: Option<SessionContext>,
865    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
866    pub events: Vec<Event>,
867}
868
869/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
870#[derive(Debug, Serialize, Deserialize)]
871#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
872#[cfg_attr(feature = "ts", ts(export))]
873pub struct StreamEventsResponse {
874    pub accepted: usize,
875}
876
877// ─── Health ──────────────────────────────────────────────────────────────────
878
879/// Returned by `GET /api/health` — server liveness check.
880#[derive(Debug, Serialize, Deserialize)]
881#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
882#[cfg_attr(feature = "ts", ts(export))]
883pub struct HealthResponse {
884    pub status: String,
885    pub version: String,
886}
887
888// ─── Service Error ───────────────────────────────────────────────────────────
889
890/// Framework-agnostic service error.
891///
892/// Each variant maps to an HTTP status code. Both the Axum server and
893/// Cloudflare Worker convert this into the appropriate response type.
894#[derive(Debug, Clone)]
895#[non_exhaustive]
896pub enum ServiceError {
897    BadRequest(String),
898    Unauthorized(String),
899    Forbidden(String),
900    NotFound(String),
901    Conflict(String),
902    Internal(String),
903}
904
905impl ServiceError {
906    /// HTTP status code as a `u16`.
907    pub fn status_code(&self) -> u16 {
908        match self {
909            Self::BadRequest(_) => 400,
910            Self::Unauthorized(_) => 401,
911            Self::Forbidden(_) => 403,
912            Self::NotFound(_) => 404,
913            Self::Conflict(_) => 409,
914            Self::Internal(_) => 500,
915        }
916    }
917
918    /// The error message.
919    pub fn message(&self) -> &str {
920        match self {
921            Self::BadRequest(m)
922            | Self::Unauthorized(m)
923            | Self::Forbidden(m)
924            | Self::NotFound(m)
925            | Self::Conflict(m)
926            | Self::Internal(m) => m,
927        }
928    }
929
930    /// Build a closure that logs a DB/IO error and returns `Internal`.
931    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
932        move |e| Self::Internal(format!("{context}: {e}"))
933    }
934}
935
936impl std::fmt::Display for ServiceError {
937    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
938        write!(f, "{}", self.message())
939    }
940}
941
942impl std::error::Error for ServiceError {}
943
944// ─── Error (legacy JSON shape) ──────────────────────────────────────────────
945
946/// Legacy JSON error shape `{ "error": "..." }` returned by all error responses.
947#[derive(Debug, Serialize)]
948#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
949#[cfg_attr(feature = "ts", ts(export))]
950pub struct ApiError {
951    pub error: String,
952}
953
954impl From<&ServiceError> for ApiError {
955    fn from(e: &ServiceError) -> Self {
956        Self {
957            error: e.message().to_string(),
958        }
959    }
960}
961
962// ─── TypeScript generation ───────────────────────────────────────────────────
963
964#[cfg(all(test, feature = "ts"))]
965mod tests {
966    use super::*;
967    use std::io::Write;
968    use std::path::PathBuf;
969    use ts_rs::TS;
970
971    /// Run with: cargo test -p opensession-api -- export_typescript --nocapture
972    #[test]
973    fn export_typescript() {
974        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
975            .join("../../packages/ui/src/api-types.generated.ts");
976
977        let cfg = ts_rs::Config::new().with_large_int("number");
978        let mut parts: Vec<String> = Vec::new();
979        parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
980        parts.push(
981            "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
982        );
983        parts.push(String::new());
984
985        // Collect all type declarations.
986        // Structs: `type X = {...}` → `export interface X {...}`
987        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
988        macro_rules! collect_ts {
989            ($($t:ty),+ $(,)?) => {
990                $(
991                    let decl = <$t>::decl(&cfg);
992                    let decl = if decl.contains(" = {") {
993                        // Struct → export interface
994                        decl
995                            .replacen("type ", "export interface ", 1)
996                            .replace(" = {", " {")
997                            .trim_end_matches(';')
998                            .to_string()
999                    } else {
1000                        // Enum/union → export type
1001                        decl
1002                            .replacen("type ", "export type ", 1)
1003                            .trim_end_matches(';')
1004                            .to_string()
1005                    };
1006                    parts.push(decl);
1007                    parts.push(String::new());
1008                )+
1009            };
1010        }
1011
1012        collect_ts!(
1013            // Shared enums
1014            TeamRole,
1015            InvitationStatus,
1016            SortOrder,
1017            TimeRange,
1018            LinkType,
1019            // Auth
1020            RegisterRequest,
1021            AuthRegisterRequest,
1022            LoginRequest,
1023            AuthTokenResponse,
1024            RefreshRequest,
1025            LogoutRequest,
1026            ChangePasswordRequest,
1027            RegisterResponse,
1028            VerifyResponse,
1029            UserSettingsResponse,
1030            OkResponse,
1031            RegenerateKeyResponse,
1032            OAuthLinkResponse,
1033            // Sessions
1034            UploadResponse,
1035            SessionSummary,
1036            SessionListResponse,
1037            SessionListQuery,
1038            SessionDetail,
1039            SessionLink,
1040            // Teams
1041            CreateTeamRequest,
1042            TeamResponse,
1043            ListTeamsResponse,
1044            TeamDetailResponse,
1045            UpdateTeamRequest,
1046            TeamStatsQuery,
1047            TeamStatsResponse,
1048            TeamStatsTotals,
1049            UserStats,
1050            ToolStats,
1051            CreateTeamInviteKeyRequest,
1052            CreateTeamInviteKeyResponse,
1053            TeamInviteKeySummary,
1054            ListTeamInviteKeysResponse,
1055            JoinTeamWithKeyRequest,
1056            JoinTeamWithKeyResponse,
1057            // Members
1058            AddMemberRequest,
1059            MemberResponse,
1060            ListMembersResponse,
1061            // Invitations
1062            InviteRequest,
1063            InvitationResponse,
1064            ListInvitationsResponse,
1065            AcceptInvitationResponse,
1066            // OAuth
1067            oauth::AuthProvidersResponse,
1068            oauth::OAuthProviderInfo,
1069            oauth::LinkedProvider,
1070            // Health
1071            HealthResponse,
1072            ApiError,
1073        );
1074
1075        let content = parts.join("\n");
1076
1077        // Write to file
1078        if let Some(parent) = out_dir.parent() {
1079            std::fs::create_dir_all(parent).ok();
1080        }
1081        let mut file = std::fs::File::create(&out_dir)
1082            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
1083        file.write_all(content.as_bytes())
1084            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
1085
1086        println!("Generated TypeScript types at: {}", out_dir.display());
1087    }
1088}