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// ─── Auth ────────────────────────────────────────────────────────────────────
25
26/// Legacy register (nickname-only). Kept for backward compatibility with CLI.
27#[derive(Debug, Deserialize)]
28#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
29#[cfg_attr(feature = "ts", ts(export))]
30pub struct RegisterRequest {
31    pub nickname: String,
32}
33
34/// Email + password registration.
35#[derive(Debug, Serialize, Deserialize)]
36#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
37#[cfg_attr(feature = "ts", ts(export))]
38pub struct AuthRegisterRequest {
39    pub email: String,
40    pub password: String,
41    pub nickname: String,
42}
43
44/// Email + password login.
45#[derive(Debug, Serialize, Deserialize)]
46#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
47#[cfg_attr(feature = "ts", ts(export))]
48pub struct LoginRequest {
49    pub email: String,
50    pub password: String,
51}
52
53/// Returned on successful login / register / refresh.
54#[derive(Debug, Serialize, Deserialize)]
55#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
56#[cfg_attr(feature = "ts", ts(export))]
57pub struct AuthTokenResponse {
58    pub access_token: String,
59    pub refresh_token: String,
60    pub expires_in: u64,
61    pub user_id: String,
62    pub nickname: String,
63}
64
65/// Refresh token request.
66#[derive(Debug, Serialize, Deserialize)]
67#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
68#[cfg_attr(feature = "ts", ts(export))]
69pub struct RefreshRequest {
70    pub refresh_token: String,
71}
72
73/// Logout request (invalidate refresh token).
74#[derive(Debug, Serialize, Deserialize)]
75#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
76#[cfg_attr(feature = "ts", ts(export))]
77pub struct LogoutRequest {
78    pub refresh_token: String,
79}
80
81/// Change password request.
82#[derive(Debug, Serialize, Deserialize)]
83#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
84#[cfg_attr(feature = "ts", ts(export))]
85pub struct ChangePasswordRequest {
86    pub current_password: String,
87    pub new_password: String,
88}
89
90/// Returned on successful legacy register (nickname-only, CLI-compatible).
91#[derive(Debug, Serialize)]
92#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
93#[cfg_attr(feature = "ts", ts(export))]
94pub struct RegisterResponse {
95    pub user_id: String,
96    pub nickname: String,
97    pub api_key: String,
98    pub is_admin: bool,
99}
100
101/// Returned by `POST /api/auth/verify` — confirms token validity.
102#[derive(Debug, Serialize, Deserialize)]
103#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
104#[cfg_attr(feature = "ts", ts(export))]
105pub struct VerifyResponse {
106    pub user_id: String,
107    pub nickname: String,
108}
109
110/// Full user profile returned by `GET /api/auth/me`.
111#[derive(Debug, Serialize, Deserialize)]
112#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
113#[cfg_attr(feature = "ts", ts(export))]
114pub struct UserSettingsResponse {
115    pub user_id: String,
116    pub nickname: String,
117    pub api_key: String,
118    pub is_admin: bool,
119    pub created_at: String,
120    pub email: Option<String>,
121    pub avatar_url: Option<String>,
122    /// Linked OAuth providers (generic — replaces github_username)
123    #[serde(default)]
124    pub oauth_providers: Vec<oauth::LinkedProvider>,
125    /// Legacy: GitHub username (populated from oauth_providers for backward compat)
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub github_username: Option<String>,
128}
129
130/// Generic success response for operations that don't return data.
131#[derive(Debug, Serialize, Deserialize)]
132#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
133#[cfg_attr(feature = "ts", ts(export))]
134pub struct OkResponse {
135    pub ok: bool,
136}
137
138/// Response for API key regeneration.
139#[derive(Debug, Serialize, Deserialize)]
140#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
141#[cfg_attr(feature = "ts", ts(export))]
142pub struct RegenerateKeyResponse {
143    pub api_key: String,
144}
145
146/// Response for OAuth link initiation (redirect URL).
147#[derive(Debug, Serialize)]
148#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
149#[cfg_attr(feature = "ts", ts(export))]
150pub struct OAuthLinkResponse {
151    pub url: String,
152}
153
154// ─── Sessions ────────────────────────────────────────────────────────────────
155
156/// Request body for `POST /api/sessions` — upload a recorded session.
157#[derive(Debug, Serialize, Deserialize)]
158#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
159#[cfg_attr(feature = "ts", ts(export))]
160pub struct UploadRequest {
161    #[cfg_attr(feature = "ts", ts(type = "any"))]
162    pub session: serde_json::Value, // Full HAIL Session JSON
163    pub team_id: Option<String>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub body_url: Option<String>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub linked_session_ids: Option<Vec<String>>,
168}
169
170/// Returned on successful session upload — contains the new session ID and URL.
171#[derive(Debug, Serialize, Deserialize)]
172#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
173#[cfg_attr(feature = "ts", ts(export))]
174pub struct UploadResponse {
175    pub id: String,
176    pub url: String,
177}
178
179/// Flat session summary returned by list/detail endpoints.
180/// This is NOT the full HAIL Session — it's a DB-derived summary.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
183#[cfg_attr(feature = "ts", ts(export))]
184pub struct SessionSummary {
185    pub id: String,
186    pub user_id: Option<String>,
187    pub nickname: Option<String>,
188    pub team_id: String,
189    pub tool: String,
190    pub agent_provider: Option<String>,
191    pub agent_model: Option<String>,
192    pub title: Option<String>,
193    pub description: Option<String>,
194    /// Comma-separated tags string
195    pub tags: Option<String>,
196    pub created_at: String,
197    pub uploaded_at: String,
198    pub message_count: i64,
199    pub task_count: i64,
200    pub event_count: i64,
201    pub duration_seconds: i64,
202    pub total_input_tokens: i64,
203    pub total_output_tokens: i64,
204}
205
206/// Paginated session listing returned by `GET /api/sessions`.
207#[derive(Debug, Serialize, Deserialize)]
208#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
209#[cfg_attr(feature = "ts", ts(export))]
210pub struct SessionListResponse {
211    pub sessions: Vec<SessionSummary>,
212    pub total: i64,
213    pub page: u32,
214    pub per_page: u32,
215}
216
217/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting.
218#[derive(Debug, Deserialize)]
219#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
220#[cfg_attr(feature = "ts", ts(export))]
221pub struct SessionListQuery {
222    #[serde(default = "default_page")]
223    pub page: u32,
224    #[serde(default = "default_per_page")]
225    pub per_page: u32,
226    pub search: Option<String>,
227    pub tool: Option<String>,
228    pub team_id: Option<String>,
229    /// Sort order: "recent" (default), "popular" (message_count desc), "longest"
230    pub sort: Option<String>,
231    /// Time range filter: "24h", "7d", "30d", "all" (default)
232    pub time_range: Option<String>,
233}
234
235fn default_page() -> u32 {
236    1
237}
238fn default_per_page() -> u32 {
239    20
240}
241
242/// Single session detail returned by `GET /api/sessions/:id`.
243#[derive(Debug, Serialize, Deserialize)]
244#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
245#[cfg_attr(feature = "ts", ts(export))]
246pub struct SessionDetail {
247    #[serde(flatten)]
248    #[cfg_attr(feature = "ts", ts(flatten))]
249    pub summary: SessionSummary,
250    pub team_name: Option<String>,
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub linked_sessions: Vec<SessionLink>,
253}
254
255/// A link between two sessions (e.g., handoff chain).
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
258#[cfg_attr(feature = "ts", ts(export))]
259pub struct SessionLink {
260    pub session_id: String,
261    pub linked_session_id: String,
262    pub link_type: String,
263    pub created_at: String,
264}
265
266// ─── Teams ──────────────────────────────────────────────────────────────────
267
268/// Request body for `POST /api/teams` — create a new team.
269#[derive(Debug, Serialize, Deserialize)]
270#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
271#[cfg_attr(feature = "ts", ts(export))]
272pub struct CreateTeamRequest {
273    pub name: String,
274    pub description: Option<String>,
275    pub is_public: Option<bool>,
276}
277
278/// Single team record returned by list and detail endpoints.
279#[derive(Debug, Serialize, Deserialize)]
280#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
281#[cfg_attr(feature = "ts", ts(export))]
282pub struct TeamResponse {
283    pub id: String,
284    pub name: String,
285    pub description: Option<String>,
286    pub is_public: bool,
287    pub created_by: String,
288    pub created_at: String,
289}
290
291/// Returned by `GET /api/teams` — teams the authenticated user belongs to.
292#[derive(Debug, Serialize, Deserialize)]
293#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
294#[cfg_attr(feature = "ts", ts(export))]
295pub struct ListTeamsResponse {
296    pub teams: Vec<TeamResponse>,
297}
298
299/// Returned by `GET /api/teams/:id` — team info with member count and recent sessions.
300#[derive(Debug, Serialize, Deserialize)]
301#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
302#[cfg_attr(feature = "ts", ts(export))]
303pub struct TeamDetailResponse {
304    #[serde(flatten)]
305    #[cfg_attr(feature = "ts", ts(flatten))]
306    pub team: TeamResponse,
307    pub member_count: i64,
308    pub sessions: Vec<SessionSummary>,
309}
310
311/// Request body for `PUT /api/teams/:id` — partial team update.
312#[derive(Debug, Serialize, Deserialize)]
313#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
314#[cfg_attr(feature = "ts", ts(export))]
315pub struct UpdateTeamRequest {
316    pub name: Option<String>,
317    pub description: Option<String>,
318    pub is_public: Option<bool>,
319}
320
321/// Query parameters for `GET /api/teams/:id/stats`.
322#[derive(Debug, Deserialize)]
323#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
324#[cfg_attr(feature = "ts", ts(export))]
325pub struct TeamStatsQuery {
326    /// Time range filter: "24h", "7d", "30d", "all" (default)
327    pub time_range: Option<String>,
328}
329
330/// Returned by `GET /api/teams/:id/stats` — aggregated team statistics.
331#[derive(Debug, Serialize)]
332#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
333#[cfg_attr(feature = "ts", ts(export))]
334pub struct TeamStatsResponse {
335    pub team_id: String,
336    pub time_range: String,
337    pub totals: TeamStatsTotals,
338    pub by_user: Vec<UserStats>,
339    pub by_tool: Vec<ToolStats>,
340}
341
342/// Aggregate totals across all sessions in a team.
343#[derive(Debug, Serialize)]
344#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
345#[cfg_attr(feature = "ts", ts(export))]
346pub struct TeamStatsTotals {
347    pub session_count: i64,
348    pub message_count: i64,
349    pub event_count: i64,
350    pub tool_call_count: i64,
351    pub duration_seconds: i64,
352    pub total_input_tokens: i64,
353    pub total_output_tokens: i64,
354}
355
356/// Per-user aggregated statistics within a team.
357#[derive(Debug, Serialize)]
358#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
359#[cfg_attr(feature = "ts", ts(export))]
360pub struct UserStats {
361    pub user_id: String,
362    pub nickname: String,
363    pub session_count: i64,
364    pub message_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}
370
371/// Per-tool aggregated statistics within a team.
372#[derive(Debug, Serialize)]
373#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
374#[cfg_attr(feature = "ts", ts(export))]
375pub struct ToolStats {
376    pub tool: String,
377    pub session_count: i64,
378    pub message_count: i64,
379    pub event_count: i64,
380    pub duration_seconds: i64,
381    pub total_input_tokens: i64,
382    pub total_output_tokens: i64,
383}
384
385// ─── From impls: core stats → API types ─────────────────────────────────────
386
387impl From<opensession_core::stats::SessionAggregate> for TeamStatsTotals {
388    fn from(a: opensession_core::stats::SessionAggregate) -> Self {
389        Self {
390            session_count: a.session_count as i64,
391            message_count: a.message_count as i64,
392            event_count: a.event_count as i64,
393            tool_call_count: a.tool_call_count as i64,
394            duration_seconds: a.duration_seconds as i64,
395            total_input_tokens: a.total_input_tokens as i64,
396            total_output_tokens: a.total_output_tokens as i64,
397        }
398    }
399}
400
401impl From<(String, opensession_core::stats::SessionAggregate)> for ToolStats {
402    fn from((tool, a): (String, opensession_core::stats::SessionAggregate)) -> Self {
403        Self {
404            tool,
405            session_count: a.session_count as i64,
406            message_count: a.message_count as i64,
407            event_count: a.event_count as i64,
408            duration_seconds: a.duration_seconds as i64,
409            total_input_tokens: a.total_input_tokens as i64,
410            total_output_tokens: a.total_output_tokens as i64,
411        }
412    }
413}
414
415// ─── Invitations ─────────────────────────────────────────────────────────────
416
417/// Request body for `POST /api/teams/:id/invite` — invite a user by email or OAuth identity.
418#[derive(Debug, Deserialize)]
419#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
420#[cfg_attr(feature = "ts", ts(export))]
421pub struct InviteRequest {
422    pub email: Option<String>,
423    /// OAuth provider name (e.g., "github", "gitlab").
424    pub oauth_provider: Option<String>,
425    /// Username on the OAuth provider (e.g., "octocat").
426    pub oauth_provider_username: Option<String>,
427    pub role: Option<String>,
428}
429
430/// Single invitation record returned by list and detail endpoints.
431#[derive(Debug, Serialize)]
432#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
433#[cfg_attr(feature = "ts", ts(export))]
434pub struct InvitationResponse {
435    pub id: String,
436    pub team_id: String,
437    pub team_name: String,
438    pub email: Option<String>,
439    pub oauth_provider: Option<String>,
440    pub oauth_provider_username: Option<String>,
441    pub invited_by_nickname: String,
442    pub role: String,
443    pub status: String,
444    pub created_at: String,
445}
446
447/// Returned by `GET /api/invitations` — pending invitations for the current user.
448#[derive(Debug, Serialize)]
449#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
450#[cfg_attr(feature = "ts", ts(export))]
451pub struct ListInvitationsResponse {
452    pub invitations: Vec<InvitationResponse>,
453}
454
455/// Returned by `POST /api/invitations/:id/accept` — confirms team join.
456#[derive(Debug, Serialize)]
457#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
458#[cfg_attr(feature = "ts", ts(export))]
459pub struct AcceptInvitationResponse {
460    pub team_id: String,
461    pub role: String,
462}
463
464// ─── Members (admin-managed) ────────────────────────────────────────────────
465
466/// Request body for `POST /api/teams/:id/members` — add a member by nickname.
467#[derive(Debug, Serialize, Deserialize)]
468#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
469#[cfg_attr(feature = "ts", ts(export))]
470pub struct AddMemberRequest {
471    pub nickname: String,
472}
473
474/// Single team member record.
475#[derive(Debug, Serialize, Deserialize)]
476#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
477#[cfg_attr(feature = "ts", ts(export))]
478pub struct MemberResponse {
479    pub user_id: String,
480    pub nickname: String,
481    pub role: String,
482    pub joined_at: String,
483}
484
485/// Returned by `GET /api/teams/:id/members`.
486#[derive(Debug, Serialize, Deserialize)]
487#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
488#[cfg_attr(feature = "ts", ts(export))]
489pub struct ListMembersResponse {
490    pub members: Vec<MemberResponse>,
491}
492
493// ─── Config Sync ─────────────────────────────────────────────────────────────
494
495/// Team-level configuration synced to CLI clients.
496#[derive(Debug, Serialize, Deserialize)]
497#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
498#[cfg_attr(feature = "ts", ts(export))]
499pub struct ConfigSyncResponse {
500    pub privacy: Option<SyncedPrivacyConfig>,
501    pub watchers: Option<SyncedWatcherConfig>,
502}
503
504/// Privacy settings synced from the team — controls what data is recorded.
505#[derive(Debug, Serialize, Deserialize)]
506#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
507#[cfg_attr(feature = "ts", ts(export))]
508pub struct SyncedPrivacyConfig {
509    pub exclude_patterns: Option<Vec<String>>,
510    pub exclude_tools: Option<Vec<String>>,
511}
512
513/// Watcher toggle settings synced from the team — which tools to monitor.
514#[derive(Debug, Serialize, Deserialize)]
515#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
516#[cfg_attr(feature = "ts", ts(export))]
517pub struct SyncedWatcherConfig {
518    pub claude_code: Option<bool>,
519    pub opencode: Option<bool>,
520    pub goose: Option<bool>,
521    pub aider: Option<bool>,
522    pub cursor: Option<bool>,
523}
524
525// ─── Sync ────────────────────────────────────────────────────────────────────
526
527/// Query parameters for `GET /api/sync/pull` — cursor-based session sync.
528#[derive(Debug, Deserialize)]
529pub struct SyncPullQuery {
530    pub team_id: String,
531    /// Cursor: uploaded_at of the last received session
532    pub since: Option<String>,
533    /// Max sessions per page (default 100)
534    pub limit: Option<u32>,
535}
536
537/// Returned by `GET /api/sync/pull` — paginated session data with cursor.
538#[derive(Debug, Serialize, Deserialize)]
539pub struct SyncPullResponse {
540    pub sessions: Vec<SessionSummary>,
541    /// Cursor for the next page (None = no more data)
542    pub next_cursor: Option<String>,
543    pub has_more: bool,
544}
545
546// ─── Streaming Events ────────────────────────────────────────────────────────
547
548/// Request body for `POST /api/sessions/:id/events` — append live events.
549#[derive(Debug, Deserialize)]
550#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
551#[cfg_attr(feature = "ts", ts(export))]
552pub struct StreamEventsRequest {
553    #[cfg_attr(feature = "ts", ts(type = "any"))]
554    pub agent: Option<serde_json::Value>,
555    #[cfg_attr(feature = "ts", ts(type = "any"))]
556    pub context: Option<serde_json::Value>,
557    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
558    pub events: Vec<serde_json::Value>,
559}
560
561/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
562#[derive(Debug, Serialize, Deserialize)]
563#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
564#[cfg_attr(feature = "ts", ts(export))]
565pub struct StreamEventsResponse {
566    pub accepted: usize,
567}
568
569// ─── Health ──────────────────────────────────────────────────────────────────
570
571/// Returned by `GET /api/health` — server liveness check.
572#[derive(Debug, Serialize, Deserialize)]
573#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
574#[cfg_attr(feature = "ts", ts(export))]
575pub struct HealthResponse {
576    pub status: String,
577    pub version: String,
578}
579
580// ─── Service Error ───────────────────────────────────────────────────────────
581
582/// Framework-agnostic service error.
583///
584/// Each variant maps to an HTTP status code. Both the Axum server and
585/// Cloudflare Worker convert this into the appropriate response type.
586#[derive(Debug, Clone)]
587#[non_exhaustive]
588pub enum ServiceError {
589    BadRequest(String),
590    Unauthorized(String),
591    Forbidden(String),
592    NotFound(String),
593    Conflict(String),
594    Internal(String),
595}
596
597impl ServiceError {
598    /// HTTP status code as a `u16`.
599    pub fn status_code(&self) -> u16 {
600        match self {
601            Self::BadRequest(_) => 400,
602            Self::Unauthorized(_) => 401,
603            Self::Forbidden(_) => 403,
604            Self::NotFound(_) => 404,
605            Self::Conflict(_) => 409,
606            Self::Internal(_) => 500,
607        }
608    }
609
610    /// The error message.
611    pub fn message(&self) -> &str {
612        match self {
613            Self::BadRequest(m)
614            | Self::Unauthorized(m)
615            | Self::Forbidden(m)
616            | Self::NotFound(m)
617            | Self::Conflict(m)
618            | Self::Internal(m) => m,
619        }
620    }
621
622    /// Build a closure that logs a DB/IO error and returns `Internal`.
623    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
624        move |e| Self::Internal(format!("{context}: {e}"))
625    }
626}
627
628impl std::fmt::Display for ServiceError {
629    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
630        write!(f, "{}", self.message())
631    }
632}
633
634impl std::error::Error for ServiceError {}
635
636// ─── Error (legacy JSON shape) ──────────────────────────────────────────────
637
638/// Legacy JSON error shape `{ "error": "..." }` returned by all error responses.
639#[derive(Debug, Serialize)]
640#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
641#[cfg_attr(feature = "ts", ts(export))]
642pub struct ApiError {
643    pub error: String,
644}
645
646impl From<&ServiceError> for ApiError {
647    fn from(e: &ServiceError) -> Self {
648        Self {
649            error: e.message().to_string(),
650        }
651    }
652}
653
654// ─── TypeScript generation ───────────────────────────────────────────────────
655
656#[cfg(all(test, feature = "ts"))]
657mod tests {
658    use super::*;
659    use std::io::Write;
660    use std::path::PathBuf;
661    use ts_rs::TS;
662
663    /// Run with: cargo test -p opensession-api-types -- export_typescript --nocapture
664    #[test]
665    fn export_typescript() {
666        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
667            .join("../../packages/ui/src/api-types.generated.ts");
668
669        let cfg = ts_rs::Config::new().with_large_int("number");
670        let mut parts: Vec<String> = Vec::new();
671        parts.push("// AUTO-GENERATED by opensession-api-types — DO NOT EDIT".to_string());
672        parts.push(
673            "// Regenerate with: cargo test -p opensession-api-types -- export_typescript"
674                .to_string(),
675        );
676        parts.push(String::new());
677
678        // Collect all type declarations, converting `type X = {...}` to `export interface X {...}`
679        macro_rules! collect_ts {
680            ($($t:ty),+ $(,)?) => {
681                $(
682                    let decl = <$t>::decl(&cfg);
683                    // Convert `type Foo = { ... };` to `export interface Foo { ... }`
684                    let decl = decl
685                        .replacen("type ", "export interface ", 1)
686                        .replace(" = {", " {")
687                        .trim_end_matches(';')
688                        .to_string();
689                    parts.push(decl);
690                    parts.push(String::new());
691                )+
692            };
693        }
694
695        collect_ts!(
696            // Auth
697            RegisterRequest,
698            AuthRegisterRequest,
699            LoginRequest,
700            AuthTokenResponse,
701            RefreshRequest,
702            LogoutRequest,
703            ChangePasswordRequest,
704            RegisterResponse,
705            VerifyResponse,
706            UserSettingsResponse,
707            OkResponse,
708            RegenerateKeyResponse,
709            OAuthLinkResponse,
710            // Sessions
711            UploadResponse,
712            SessionSummary,
713            SessionListResponse,
714            SessionListQuery,
715            SessionDetail,
716            SessionLink,
717            // Teams
718            CreateTeamRequest,
719            TeamResponse,
720            ListTeamsResponse,
721            TeamDetailResponse,
722            UpdateTeamRequest,
723            TeamStatsQuery,
724            TeamStatsResponse,
725            TeamStatsTotals,
726            UserStats,
727            ToolStats,
728            // Members
729            AddMemberRequest,
730            MemberResponse,
731            ListMembersResponse,
732            // Invitations
733            InviteRequest,
734            InvitationResponse,
735            ListInvitationsResponse,
736            AcceptInvitationResponse,
737            // OAuth
738            oauth::AuthProvidersResponse,
739            oauth::OAuthProviderInfo,
740            oauth::LinkedProvider,
741            // Health
742            HealthResponse,
743            ApiError,
744        );
745
746        let content = parts.join("\n");
747
748        // Write to file
749        if let Some(parent) = out_dir.parent() {
750            std::fs::create_dir_all(parent).ok();
751        }
752        let mut file = std::fs::File::create(&out_dir)
753            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
754        file.write_all(content.as_bytes())
755            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
756
757        println!("Generated TypeScript types at: {}", out_dir.display());
758    }
759}