feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)

This commit is contained in:
2026-08-11 16:24:20 +02:00
parent 25ad44a3d9
commit 94b4ce7e5f
23 changed files with 1552 additions and 70 deletions

View File

@ -0,0 +1,157 @@
//! Custom chat draft use cases.
use std::sync::Arc;
use domain::ports::{ChatDraftStore, Clock, ProjectStore};
use domain::{ChatDraft, ChatDraftKey, LayoutId, NodeId, Project, ProjectId};
use crate::AppError;
/// Input identifying one draft scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChatDraftScopeInput {
/// Project owning the layout.
pub project_id: ProjectId,
/// Named layout containing the chat cell.
pub layout_id: LayoutId,
/// Layout node hosting the chat cell.
pub node_id: NodeId,
}
impl ChatDraftScopeInput {
fn key(self) -> ChatDraftKey {
ChatDraftKey::new(self.project_id, self.layout_id, self.node_id)
}
}
/// Output of [`ReadChatDraft`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadChatDraftOutput {
/// Restored draft text, or `None` when no draft exists.
pub text: Option<String>,
/// Last update time when a draft exists.
pub updated_at_ms: Option<u64>,
}
/// Input for [`SaveChatDraft`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveChatDraftInput {
/// Draft scope.
pub scope: ChatDraftScopeInput,
/// Text to persist. Empty text clears the draft.
pub text: String,
}
/// Reads a persisted custom chat draft.
pub struct ReadChatDraft {
projects: Arc<dyn ProjectStore>,
drafts: Arc<dyn ChatDraftStore>,
}
impl ReadChatDraft {
/// Builds the use case.
#[must_use]
pub fn new(projects: Arc<dyn ProjectStore>, drafts: Arc<dyn ChatDraftStore>) -> Self {
Self { projects, drafts }
}
/// Executes the read.
pub async fn execute(
&self,
input: ChatDraftScopeInput,
) -> Result<ReadChatDraftOutput, AppError> {
let project = self.load_project(input.project_id).await?;
let draft = self.drafts.read(&project.root, input.key()).await?;
Ok(ReadChatDraftOutput {
text: draft.as_ref().map(|draft| draft.text.clone()),
updated_at_ms: draft.map(|draft| draft.updated_at_ms),
})
}
async fn load_project(&self, project_id: ProjectId) -> Result<Project, AppError> {
self.projects
.load_project(project_id)
.await
.map_err(AppError::from)
}
}
/// Saves a persisted custom chat draft.
pub struct SaveChatDraft {
projects: Arc<dyn ProjectStore>,
drafts: Arc<dyn ChatDraftStore>,
clock: Arc<dyn Clock>,
}
impl SaveChatDraft {
/// Builds the use case.
#[must_use]
pub fn new(
projects: Arc<dyn ProjectStore>,
drafts: Arc<dyn ChatDraftStore>,
clock: Arc<dyn Clock>,
) -> Self {
Self {
projects,
drafts,
clock,
}
}
/// Executes the save. Empty text clears the draft.
pub async fn execute(&self, input: SaveChatDraftInput) -> Result<(), AppError> {
let project = self.load_project(input.scope.project_id).await?;
let key = input.scope.key();
if input.text.is_empty() {
return self
.drafts
.clear(&project.root, key)
.await
.map_err(AppError::from);
}
let updated_at_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
let draft = ChatDraft::new(key, input.text, updated_at_ms)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.drafts
.save(&project.root, draft)
.await
.map_err(AppError::from)
}
async fn load_project(&self, project_id: ProjectId) -> Result<Project, AppError> {
self.projects
.load_project(project_id)
.await
.map_err(AppError::from)
}
}
/// Clears a persisted custom chat draft.
pub struct ClearChatDraft {
projects: Arc<dyn ProjectStore>,
drafts: Arc<dyn ChatDraftStore>,
}
impl ClearChatDraft {
/// Builds the use case.
#[must_use]
pub fn new(projects: Arc<dyn ProjectStore>, drafts: Arc<dyn ChatDraftStore>) -> Self {
Self { projects, drafts }
}
/// Executes the clear.
pub async fn execute(&self, input: ChatDraftScopeInput) -> Result<(), AppError> {
let project = self.load_project(input.project_id).await?;
self.drafts
.clear(&project.root, input.key())
.await
.map_err(AppError::from)
}
async fn load_project(&self, project_id: ProjectId) -> Result<Project, AppError> {
self.projects
.load_project(project_id)
.await
.map_err(AppError::from)
}
}