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