feat(tickets): ajoute les pièces jointes sur tickets (#108)

Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté
backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le
domaine (Issue, ports), l'application (usecases + assistant de ticket), les
adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web-
server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 11:08:45 +02:00
parent 2692b9cc03
commit 8158057b1d
34 changed files with 1722 additions and 102 deletions

View File

@ -10,9 +10,10 @@ use application::{
BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput,
};
use domain::{
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
Project, SprintId, SprintStatus, SprintVersion,
AgentId, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentContent,
IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, Project, SprintId, SprintStatus,
SprintVersion,
};
use infrastructure::TicketToolError;
@ -36,6 +37,7 @@ pub struct TicketDto {
pub carnet: Option<String>,
pub links: Vec<TicketLinkDto>,
pub assigned_agent_ids: Vec<String>,
pub attachments: Vec<TicketAttachmentDto>,
pub created_by: TicketActorDto,
pub updated_by: TicketActorDto,
pub created_at: u64,
@ -69,6 +71,31 @@ pub struct TicketListDto {
pub next_cursor: Option<String>,
}
/// Public attachment metadata DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentDto {
pub id: String,
pub filename: String,
pub mime: String,
pub size_bytes: u64,
pub added_by: TicketActorDto,
pub added_at: u64,
pub summarized_in_carnet: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub summarized_by: Option<TicketActorDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summarized_at: Option<u64>,
}
/// Raw attachment content DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentContentDto {
pub attachment: TicketAttachmentDto,
pub content_base64: String,
}
/// Sprint context embedded in ticket read/list responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -186,6 +213,39 @@ pub struct TicketDeleteRequestDto {
pub r#ref: String,
}
/// Add attachment request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentAddRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub path: String,
pub mime: Option<String>,
pub expected_version: u64,
}
/// Read attachment request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentReadRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub attachment_id: String,
}
/// Mark attachment summarized request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentMarkSummarizedRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub attachment_id: String,
pub expected_version: u64,
}
/// List request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -437,6 +497,11 @@ impl TicketDto {
.filter(|r| r.role == AgentIssueRole::Assigned)
.map(|r| r.agent_id.to_string())
.collect(),
attachments: issue
.attachments
.into_iter()
.map(TicketAttachmentDto::from)
.collect(),
created_by: TicketActorDto::from(issue.created_by),
updated_by: TicketActorDto::from(issue.updated_by),
created_at: issue.created_at,
@ -547,6 +612,31 @@ impl From<IssueActor> for TicketActorDto {
}
}
impl From<IssueAttachment> for TicketAttachmentDto {
fn from(attachment: IssueAttachment) -> Self {
Self {
id: attachment.id.as_str().to_owned(),
filename: attachment.filename,
mime: attachment.mime,
size_bytes: attachment.size_bytes,
added_by: TicketActorDto::from(attachment.added_by),
added_at: attachment.added_at,
summarized_in_carnet: attachment.summarized_in_carnet,
summarized_by: attachment.summarized_by.map(TicketActorDto::from),
summarized_at: attachment.summarized_at,
}
}
}
impl From<IssueAttachmentContent> for TicketAttachmentContentDto {
fn from(content: IssueAttachmentContent) -> Self {
Self {
attachment: TicketAttachmentDto::from(content.attachment),
content_base64: URL_SAFE_NO_PAD.encode(content.bytes),
}
}
}
impl TryFrom<TicketCreatorFilterDto> for IssueActor {
type Error = ErrorDto;
@ -560,6 +650,13 @@ impl TryFrom<TicketCreatorFilterDto> for IssueActor {
}
}
pub fn parse_attachment_id_dto(raw: &str) -> Result<IssueAttachmentId, ErrorDto> {
IssueAttachmentId::new(raw.to_owned()).map_err(|err| ErrorDto {
code: "INVALID".to_owned(),
message: err.to_string(),
})
}
impl From<IssueCarnet> for TicketCarnetDto {
fn from(carnet: IssueCarnet) -> Self {
Self {