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