Skip to main content

opensession_api_types/
lib.rs

1//! Shared API types 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-types -- export_typescript --nocapture
8
9use serde::{Deserialize, Serialize};
10
11#[cfg(feature = "server")]
12pub mod crypto;
13#[cfg(feature = "server")]
14pub mod db;
15pub mod oauth;
16#[cfg(feature = "server")]
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}
383
384/// Paginated session listing returned by `GET /api/sessions`.
385#[derive(Debug, Serialize, Deserialize)]
386#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
387#[cfg_attr(feature = "ts", ts(export))]
388pub struct SessionListResponse {
389    pub sessions: Vec<SessionSummary>,
390    pub total: i64,
391    pub page: u32,
392    pub per_page: u32,
393}
394
395/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting.
396#[derive(Debug, Deserialize)]
397#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
398#[cfg_attr(feature = "ts", ts(export))]
399pub struct SessionListQuery {
400    #[serde(default = "default_page")]
401    pub page: u32,
402    #[serde(default = "default_per_page")]
403    pub per_page: u32,
404    pub search: Option<String>,
405    pub tool: Option<String>,
406    pub team_id: Option<String>,
407    /// Sort order (default: recent)
408    pub sort: Option<SortOrder>,
409    /// Time range filter (default: all)
410    pub time_range: Option<TimeRange>,
411}
412
413fn default_page() -> u32 {
414    1
415}
416fn default_per_page() -> u32 {
417    20
418}
419
420/// Single session detail returned by `GET /api/sessions/:id`.
421#[derive(Debug, Serialize, Deserialize)]
422#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
423#[cfg_attr(feature = "ts", ts(export))]
424pub struct SessionDetail {
425    #[serde(flatten)]
426    #[cfg_attr(feature = "ts", ts(flatten))]
427    pub summary: SessionSummary,
428    pub team_name: Option<String>,
429    #[serde(default, skip_serializing_if = "Vec::is_empty")]
430    pub linked_sessions: Vec<SessionLink>,
431}
432
433/// A link between two sessions (e.g., handoff chain).
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
436#[cfg_attr(feature = "ts", ts(export))]
437pub struct SessionLink {
438    pub session_id: String,
439    pub linked_session_id: String,
440    pub link_type: LinkType,
441    pub created_at: String,
442}
443
444// ─── Teams ──────────────────────────────────────────────────────────────────
445
446/// Request body for `POST /api/teams` — create a new team.
447#[derive(Debug, Serialize, Deserialize)]
448#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
449#[cfg_attr(feature = "ts", ts(export))]
450pub struct CreateTeamRequest {
451    pub name: String,
452    pub description: Option<String>,
453    pub is_public: Option<bool>,
454}
455
456/// Single team record returned by list and detail endpoints.
457#[derive(Debug, Serialize, Deserialize)]
458#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
459#[cfg_attr(feature = "ts", ts(export))]
460pub struct TeamResponse {
461    pub id: String,
462    pub name: String,
463    pub description: Option<String>,
464    pub is_public: bool,
465    pub created_by: String,
466    pub created_at: String,
467}
468
469/// Returned by `GET /api/teams` — teams the authenticated user belongs to.
470#[derive(Debug, Serialize, Deserialize)]
471#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
472#[cfg_attr(feature = "ts", ts(export))]
473pub struct ListTeamsResponse {
474    pub teams: Vec<TeamResponse>,
475}
476
477/// Returned by `GET /api/teams/:id` — team info with member count and recent sessions.
478#[derive(Debug, Serialize, Deserialize)]
479#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
480#[cfg_attr(feature = "ts", ts(export))]
481pub struct TeamDetailResponse {
482    #[serde(flatten)]
483    #[cfg_attr(feature = "ts", ts(flatten))]
484    pub team: TeamResponse,
485    pub member_count: i64,
486    pub sessions: Vec<SessionSummary>,
487}
488
489/// Request body for `PUT /api/teams/:id` — partial team update.
490#[derive(Debug, Serialize, Deserialize)]
491#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
492#[cfg_attr(feature = "ts", ts(export))]
493pub struct UpdateTeamRequest {
494    pub name: Option<String>,
495    pub description: Option<String>,
496    pub is_public: Option<bool>,
497}
498
499/// Query parameters for `GET /api/teams/:id/stats`.
500#[derive(Debug, Deserialize)]
501#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
502#[cfg_attr(feature = "ts", ts(export))]
503pub struct TeamStatsQuery {
504    /// Time range filter (default: all)
505    pub time_range: Option<TimeRange>,
506}
507
508/// Returned by `GET /api/teams/:id/stats` — aggregated team statistics.
509#[derive(Debug, Serialize)]
510#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
511#[cfg_attr(feature = "ts", ts(export))]
512pub struct TeamStatsResponse {
513    pub team_id: String,
514    pub time_range: TimeRange,
515    pub totals: TeamStatsTotals,
516    pub by_user: Vec<UserStats>,
517    pub by_tool: Vec<ToolStats>,
518}
519
520/// Aggregate totals across all sessions in a team.
521#[derive(Debug, Serialize)]
522#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
523#[cfg_attr(feature = "ts", ts(export))]
524pub struct TeamStatsTotals {
525    pub session_count: i64,
526    pub message_count: i64,
527    pub event_count: i64,
528    pub tool_call_count: i64,
529    pub duration_seconds: i64,
530    pub total_input_tokens: i64,
531    pub total_output_tokens: i64,
532}
533
534/// Per-user aggregated statistics within a team.
535#[derive(Debug, Serialize)]
536#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
537#[cfg_attr(feature = "ts", ts(export))]
538pub struct UserStats {
539    pub user_id: String,
540    pub nickname: String,
541    pub session_count: i64,
542    pub message_count: i64,
543    pub event_count: i64,
544    pub duration_seconds: i64,
545    pub total_input_tokens: i64,
546    pub total_output_tokens: i64,
547}
548
549/// Per-tool aggregated statistics within a team.
550#[derive(Debug, Serialize)]
551#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
552#[cfg_attr(feature = "ts", ts(export))]
553pub struct ToolStats {
554    pub tool: String,
555    pub session_count: i64,
556    pub message_count: i64,
557    pub event_count: i64,
558    pub duration_seconds: i64,
559    pub total_input_tokens: i64,
560    pub total_output_tokens: i64,
561}
562
563// ─── From impls: core stats → API types ─────────────────────────────────────
564
565impl From<opensession_core::stats::SessionAggregate> for TeamStatsTotals {
566    fn from(a: opensession_core::stats::SessionAggregate) -> Self {
567        Self {
568            session_count: saturating_i64(a.session_count),
569            message_count: saturating_i64(a.message_count),
570            event_count: saturating_i64(a.event_count),
571            tool_call_count: saturating_i64(a.tool_call_count),
572            duration_seconds: saturating_i64(a.duration_seconds),
573            total_input_tokens: saturating_i64(a.total_input_tokens),
574            total_output_tokens: saturating_i64(a.total_output_tokens),
575        }
576    }
577}
578
579impl From<(String, opensession_core::stats::SessionAggregate)> for ToolStats {
580    fn from((tool, a): (String, opensession_core::stats::SessionAggregate)) -> Self {
581        Self {
582            tool,
583            session_count: saturating_i64(a.session_count),
584            message_count: saturating_i64(a.message_count),
585            event_count: saturating_i64(a.event_count),
586            duration_seconds: saturating_i64(a.duration_seconds),
587            total_input_tokens: saturating_i64(a.total_input_tokens),
588            total_output_tokens: saturating_i64(a.total_output_tokens),
589        }
590    }
591}
592
593// ─── Invitations ─────────────────────────────────────────────────────────────
594
595/// Request body for `POST /api/teams/:id/invite` — invite a user by email or OAuth identity.
596#[derive(Debug, Serialize, Deserialize)]
597#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
598#[cfg_attr(feature = "ts", ts(export))]
599pub struct InviteRequest {
600    pub email: Option<String>,
601    /// OAuth provider name (e.g., "github", "gitlab").
602    pub oauth_provider: Option<String>,
603    /// Username on the OAuth provider (e.g., "octocat").
604    pub oauth_provider_username: Option<String>,
605    pub role: Option<TeamRole>,
606}
607
608/// Single invitation record returned by list and detail endpoints.
609#[derive(Debug, Serialize, Deserialize)]
610#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
611#[cfg_attr(feature = "ts", ts(export))]
612pub struct InvitationResponse {
613    pub id: String,
614    pub team_id: String,
615    pub team_name: String,
616    pub email: Option<String>,
617    pub oauth_provider: Option<String>,
618    pub oauth_provider_username: Option<String>,
619    pub invited_by_nickname: String,
620    pub role: TeamRole,
621    pub status: InvitationStatus,
622    pub created_at: String,
623}
624
625/// Returned by `GET /api/invitations` — pending invitations for the current user.
626#[derive(Debug, Serialize, Deserialize)]
627#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
628#[cfg_attr(feature = "ts", ts(export))]
629pub struct ListInvitationsResponse {
630    pub invitations: Vec<InvitationResponse>,
631}
632
633/// Returned by `POST /api/invitations/:id/accept` — confirms team join.
634#[derive(Debug, Serialize, Deserialize)]
635#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
636#[cfg_attr(feature = "ts", ts(export))]
637pub struct AcceptInvitationResponse {
638    pub team_id: String,
639    pub role: TeamRole,
640}
641
642// ─── Members (admin-managed) ────────────────────────────────────────────────
643
644/// Request body for `POST /api/teams/:id/members` — add a member by nickname.
645#[derive(Debug, Serialize, Deserialize)]
646#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
647#[cfg_attr(feature = "ts", ts(export))]
648pub struct AddMemberRequest {
649    pub nickname: String,
650}
651
652/// Single team member record.
653#[derive(Debug, Serialize, Deserialize)]
654#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
655#[cfg_attr(feature = "ts", ts(export))]
656pub struct MemberResponse {
657    pub user_id: String,
658    pub nickname: String,
659    pub role: TeamRole,
660    pub joined_at: String,
661}
662
663/// Returned by `GET /api/teams/:id/members`.
664#[derive(Debug, Serialize, Deserialize)]
665#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
666#[cfg_attr(feature = "ts", ts(export))]
667pub struct ListMembersResponse {
668    pub members: Vec<MemberResponse>,
669}
670
671// ─── Config Sync ─────────────────────────────────────────────────────────────
672
673/// Team-level configuration synced to CLI clients.
674#[derive(Debug, Serialize, Deserialize)]
675#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
676#[cfg_attr(feature = "ts", ts(export))]
677pub struct ConfigSyncResponse {
678    pub privacy: Option<SyncedPrivacyConfig>,
679    pub watchers: Option<SyncedWatcherConfig>,
680}
681
682/// Privacy settings synced from the team — controls what data is recorded.
683#[derive(Debug, Serialize, Deserialize)]
684#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
685#[cfg_attr(feature = "ts", ts(export))]
686pub struct SyncedPrivacyConfig {
687    pub exclude_patterns: Option<Vec<String>>,
688    pub exclude_tools: Option<Vec<String>>,
689}
690
691/// Watcher toggle settings synced from the team — which tools to monitor.
692#[derive(Debug, Serialize, Deserialize)]
693#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
694#[cfg_attr(feature = "ts", ts(export))]
695pub struct SyncedWatcherConfig {
696    pub claude_code: Option<bool>,
697    pub opencode: Option<bool>,
698    pub goose: Option<bool>,
699    pub aider: Option<bool>,
700    pub cursor: Option<bool>,
701}
702
703// ─── Sync ────────────────────────────────────────────────────────────────────
704
705/// Query parameters for `GET /api/sync/pull` — cursor-based session sync.
706#[derive(Debug, Deserialize)]
707pub struct SyncPullQuery {
708    pub team_id: String,
709    /// Cursor: uploaded_at of the last received session
710    pub since: Option<String>,
711    /// Max sessions per page (default 100)
712    pub limit: Option<u32>,
713}
714
715/// Returned by `GET /api/sync/pull` — paginated session data with cursor.
716#[derive(Debug, Serialize, Deserialize)]
717pub struct SyncPullResponse {
718    pub sessions: Vec<SessionSummary>,
719    /// Cursor for the next page (None = no more data)
720    pub next_cursor: Option<String>,
721    pub has_more: bool,
722}
723
724// ─── Streaming Events ────────────────────────────────────────────────────────
725
726/// Request body for `POST /api/sessions/:id/events` — append live events.
727#[derive(Debug, Deserialize)]
728#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
729#[cfg_attr(feature = "ts", ts(export))]
730pub struct StreamEventsRequest {
731    #[cfg_attr(feature = "ts", ts(type = "any"))]
732    pub agent: Option<Agent>,
733    #[cfg_attr(feature = "ts", ts(type = "any"))]
734    pub context: Option<SessionContext>,
735    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
736    pub events: Vec<Event>,
737}
738
739/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
740#[derive(Debug, Serialize, Deserialize)]
741#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
742#[cfg_attr(feature = "ts", ts(export))]
743pub struct StreamEventsResponse {
744    pub accepted: usize,
745}
746
747// ─── Health ──────────────────────────────────────────────────────────────────
748
749/// Returned by `GET /api/health` — server liveness check.
750#[derive(Debug, Serialize, Deserialize)]
751#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
752#[cfg_attr(feature = "ts", ts(export))]
753pub struct HealthResponse {
754    pub status: String,
755    pub version: String,
756}
757
758// ─── Service Error ───────────────────────────────────────────────────────────
759
760/// Framework-agnostic service error.
761///
762/// Each variant maps to an HTTP status code. Both the Axum server and
763/// Cloudflare Worker convert this into the appropriate response type.
764#[derive(Debug, Clone)]
765#[non_exhaustive]
766pub enum ServiceError {
767    BadRequest(String),
768    Unauthorized(String),
769    Forbidden(String),
770    NotFound(String),
771    Conflict(String),
772    Internal(String),
773}
774
775impl ServiceError {
776    /// HTTP status code as a `u16`.
777    pub fn status_code(&self) -> u16 {
778        match self {
779            Self::BadRequest(_) => 400,
780            Self::Unauthorized(_) => 401,
781            Self::Forbidden(_) => 403,
782            Self::NotFound(_) => 404,
783            Self::Conflict(_) => 409,
784            Self::Internal(_) => 500,
785        }
786    }
787
788    /// The error message.
789    pub fn message(&self) -> &str {
790        match self {
791            Self::BadRequest(m)
792            | Self::Unauthorized(m)
793            | Self::Forbidden(m)
794            | Self::NotFound(m)
795            | Self::Conflict(m)
796            | Self::Internal(m) => m,
797        }
798    }
799
800    /// Build a closure that logs a DB/IO error and returns `Internal`.
801    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
802        move |e| Self::Internal(format!("{context}: {e}"))
803    }
804}
805
806impl std::fmt::Display for ServiceError {
807    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
808        write!(f, "{}", self.message())
809    }
810}
811
812impl std::error::Error for ServiceError {}
813
814// ─── Error (legacy JSON shape) ──────────────────────────────────────────────
815
816/// Legacy JSON error shape `{ "error": "..." }` returned by all error responses.
817#[derive(Debug, Serialize)]
818#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
819#[cfg_attr(feature = "ts", ts(export))]
820pub struct ApiError {
821    pub error: String,
822}
823
824impl From<&ServiceError> for ApiError {
825    fn from(e: &ServiceError) -> Self {
826        Self {
827            error: e.message().to_string(),
828        }
829    }
830}
831
832// ─── TypeScript generation ───────────────────────────────────────────────────
833
834#[cfg(all(test, feature = "ts"))]
835mod tests {
836    use super::*;
837    use std::io::Write;
838    use std::path::PathBuf;
839    use ts_rs::TS;
840
841    /// Run with: cargo test -p opensession-api-types -- export_typescript --nocapture
842    #[test]
843    fn export_typescript() {
844        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
845            .join("../../packages/ui/src/api-types.generated.ts");
846
847        let cfg = ts_rs::Config::new().with_large_int("number");
848        let mut parts: Vec<String> = Vec::new();
849        parts.push("// AUTO-GENERATED by opensession-api-types — DO NOT EDIT".to_string());
850        parts.push(
851            "// Regenerate with: cargo test -p opensession-api-types -- export_typescript"
852                .to_string(),
853        );
854        parts.push(String::new());
855
856        // Collect all type declarations.
857        // Structs: `type X = {...}` → `export interface X {...}`
858        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
859        macro_rules! collect_ts {
860            ($($t:ty),+ $(,)?) => {
861                $(
862                    let decl = <$t>::decl(&cfg);
863                    let decl = if decl.contains(" = {") {
864                        // Struct → export interface
865                        decl
866                            .replacen("type ", "export interface ", 1)
867                            .replace(" = {", " {")
868                            .trim_end_matches(';')
869                            .to_string()
870                    } else {
871                        // Enum/union → export type
872                        decl
873                            .replacen("type ", "export type ", 1)
874                            .trim_end_matches(';')
875                            .to_string()
876                    };
877                    parts.push(decl);
878                    parts.push(String::new());
879                )+
880            };
881        }
882
883        collect_ts!(
884            // Shared enums
885            TeamRole,
886            InvitationStatus,
887            SortOrder,
888            TimeRange,
889            LinkType,
890            // Auth
891            RegisterRequest,
892            AuthRegisterRequest,
893            LoginRequest,
894            AuthTokenResponse,
895            RefreshRequest,
896            LogoutRequest,
897            ChangePasswordRequest,
898            RegisterResponse,
899            VerifyResponse,
900            UserSettingsResponse,
901            OkResponse,
902            RegenerateKeyResponse,
903            OAuthLinkResponse,
904            // Sessions
905            UploadResponse,
906            SessionSummary,
907            SessionListResponse,
908            SessionListQuery,
909            SessionDetail,
910            SessionLink,
911            // Teams
912            CreateTeamRequest,
913            TeamResponse,
914            ListTeamsResponse,
915            TeamDetailResponse,
916            UpdateTeamRequest,
917            TeamStatsQuery,
918            TeamStatsResponse,
919            TeamStatsTotals,
920            UserStats,
921            ToolStats,
922            // Members
923            AddMemberRequest,
924            MemberResponse,
925            ListMembersResponse,
926            // Invitations
927            InviteRequest,
928            InvitationResponse,
929            ListInvitationsResponse,
930            AcceptInvitationResponse,
931            // OAuth
932            oauth::AuthProvidersResponse,
933            oauth::OAuthProviderInfo,
934            oauth::LinkedProvider,
935            // Health
936            HealthResponse,
937            ApiError,
938        );
939
940        let content = parts.join("\n");
941
942        // Write to file
943        if let Some(parent) = out_dir.parent() {
944            std::fs::create_dir_all(parent).ok();
945        }
946        let mut file = std::fs::File::create(&out_dir)
947            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
948        file.write_all(content.as_bytes())
949            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
950
951        println!("Generated TypeScript types at: {}", out_dir.display());
952    }
953}