use std::str::FromStr;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use toolkit_macros::domain_model;
use uuid::Uuid;
use crate::domain::error::ChatEngineError;
use crate::domain::message::{MessagePart, MessageRole};
#[domain_model]
#[derive(Clone, PartialEq, Eq)]
pub struct ShareToken(String);
impl ShareToken {
#[must_use]
pub fn new(raw: impl Into<String>) -> Self {
Self(raw.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Debug for ShareToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ShareToken(***redacted***)")
}
}
#[must_use]
pub fn generate_share_token() -> ShareToken {
let a = Uuid::new_v4().simple().to_string();
let b = Uuid::new_v4().simple().to_string();
ShareToken(format!("{a}{b}"))
}
#[domain_model]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExportFormat {
Json,
Markdown,
}
impl ExportFormat {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Json => "json",
Self::Markdown => "markdown",
}
}
#[must_use]
pub fn content_type(&self) -> &'static str {
match self {
Self::Json => "application/json",
Self::Markdown => "text/markdown; charset=utf-8",
}
}
#[must_use]
pub fn extension(&self) -> &'static str {
match self {
Self::Json => "json",
Self::Markdown => "md",
}
}
}
impl FromStr for ExportFormat {
type Err = ChatEngineError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"json" => Ok(Self::Json),
"markdown" | "md" => Ok(Self::Markdown),
other => Err(ChatEngineError::bad_request(format!(
"unsupported export format '{other}' (expected 'json' or 'markdown')"
))),
}
}
}
#[domain_model]
#[derive(Debug, Clone, Serialize)]
pub struct ExportSessionMeta {
pub session_id: Uuid,
pub session_type_id: Option<Uuid>,
pub lifecycle_state: String,
pub title: Option<String>,
pub metadata: Option<serde_json::Value>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
#[domain_model]
#[derive(Debug, Clone, Serialize)]
pub struct MessageView {
pub message_id: Uuid,
pub role: MessageRole,
pub parts: Vec<MessagePart>,
pub metadata: Option<serde_json::Value>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
#[domain_model]
#[derive(Debug, Clone, Serialize)]
pub struct ExportedSession {
pub session: ExportSessionMeta,
pub messages: Vec<MessageView>,
pub format: ExportFormat,
#[serde(with = "time::serde::rfc3339")]
pub exported_at: OffsetDateTime,
pub download_url: String,
pub message_count: usize,
}
#[domain_model]
#[derive(Debug, Clone, Serialize)]
pub struct ShareTokenIssue {
pub share_token: String,
pub share_url: String,
#[serde(
default,
with = "time::serde::rfc3339::option",
skip_serializing_if = "Option::is_none"
)]
pub expires_at: Option<OffsetDateTime>,
}
#[domain_model]
#[derive(Debug, Clone, Serialize)]
pub struct SharedSessionView {
pub title: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
pub messages: Vec<MessageView>,
pub read_only: bool,
pub message_count: usize,
}
#[domain_model]
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("export storage unavailable: {0}")]
Unavailable(String),
#[error("export storage not implemented: {0}")]
NotImplemented(String),
}
impl From<StorageError> for ChatEngineError {
fn from(err: StorageError) -> Self {
match err {
StorageError::Unavailable(msg) => ChatEngineError::BackendUnavailable {
reason: format!("export storage unavailable: {msg}"),
retry_after: None,
source: Some(Box::new(StorageError::Unavailable(msg))),
},
StorageError::NotImplemented(reason) => ChatEngineError::not_implemented(reason),
}
}
}
#[async_trait]
pub trait ExportStorage: Send + Sync {
async fn upload(
&self,
key: &str,
bytes: Vec<u8>,
content_type: &str,
) -> Result<String, StorageError>;
}
#[domain_model]
#[derive(Debug, Default, Clone, Copy)]
pub struct StubExportStorage;
#[async_trait]
impl ExportStorage for StubExportStorage {
async fn upload(
&self,
key: &str,
_bytes: Vec<u8>,
_content_type: &str,
) -> Result<String, StorageError> {
Ok(format!("memory://exports/{key}"))
}
}
#[domain_model]
#[derive(Debug, Default, Clone, Copy)]
pub struct NotImplementedExportStorage;
#[async_trait]
impl ExportStorage for NotImplementedExportStorage {
async fn upload(
&self,
_key: &str,
_bytes: Vec<u8>,
_content_type: &str,
) -> Result<String, StorageError> {
Err(StorageError::NotImplemented(
"session export storage backend is not configured".into(),
))
}
}
#[cfg(test)]
#[path = "export_tests.rs"]
mod export_tests;