15c35c06cdea1711d13d2bf9060bc4610e4b22c2 / frogue / src / attachments / store.rs
Raw
use sea_orm::ActiveValue::Set;
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
};
use tracing::{debug, error};
use uuid::Uuid;
use crate::attachments::file_store::FileStore;
use crate::entities::attachments::{ActiveModel, Column, Entity, Model};
/// Owner type for an attachment
#[derive(Debug)]
pub enum Owner {
Ticket(i64),
Comment(i64),
ApNote(i64),
ApRemoteNote(i64),
}
impl Owner {
fn kind(&self) -> &'static str {
match self {
Owner::Ticket(_) => "ticket",
Owner::Comment(_) => "comment",
Owner::ApNote(_) => "ap_note",
Owner::ApRemoteNote(_) => "ap_remote_note",
}
}
fn id(&self) -> i64 {
match self {
Owner::Ticket(id) => *id,
Owner::Comment(id) => *id,
Owner::ApNote(id) => *id,
Owner::ApRemoteNote(id) => *id,
}
}
}
/// Create a new remote attachment (federation inbound).
///
/// Creates a database row with `remote_url` set, without writing any files.
/// Used when receiving attachments from other instances via federation.
pub async fn create_remote(
db: &DatabaseConnection,
owner: Owner,
filename: String,
content_type: String,
remote_url: String,
) -> Result<Model, sea_orm::DbErr> {
let uuid = Uuid::new_v4();
let (ticket_id, comment_id, ap_note_id, ap_remote_note_id) = match &owner {
Owner::Ticket(id) => (Some(*id), None, None, None),
Owner::Comment(id) => (None, Some(*id), None, None),
Owner::ApNote(id) => (None, None, Some(*id), None),
Owner::ApRemoteNote(id) => (None, None, None, Some(*id)),
};
let active = ActiveModel {
uuid: Set(uuid),
owner_kind: Set(owner.kind().to_string()),
owner_id: Set(owner.id()),
filename: Set(filename.clone()),
content_type: Set(content_type.clone()),
size: Set(0), // Remote attachments don't have local size
remote_url: Set(Some(remote_url)),
ticket_id: Set(ticket_id),
comment_id: Set(comment_id),
ap_note_id: Set(ap_note_id),
ap_remote_note_id: Set(ap_remote_note_id),
..Default::default()
};
match Entity::insert(active).exec(db).await {
Ok(result) => {
debug!(?uuid, ?owner, "Created remote attachment");
Entity::find_by_id(result.last_insert_id)
.one(db)
.await?
.ok_or_else(|| {
sea_orm::DbErr::RecordNotFound("Attachment not found after insert".to_string())
})
}
Err(e) => {
error!(error = %e, ?uuid, "Failed to insert remote attachment row");
Err(e)
}
}
}
/// Create a new attachment with file storage.
///
/// Writes the file first, then inserts the database row.
/// On insert failure, best-effort cleanup of the file.
pub async fn create(
db: &DatabaseConnection,
files: &FileStore,
owner: Owner,
filename: String,
content_type: String,
data: Vec<u8>,
) -> Result<Model, sea_orm::DbErr> {
let uuid = Uuid::new_v4();
let size = data.len() as i64;
// Write file first
if let Err(e) = files.write(uuid, &content_type, &data).await {
error!(error = %e, ?uuid, "Failed to write attachment file");
return Err(sea_orm::DbErr::Custom(format!(
"Failed to write attachment file: {}",
e
)));
}
// Build the model
let (ticket_id, comment_id, ap_note_id, ap_remote_note_id) = match &owner {
Owner::Ticket(id) => (Some(*id), None, None, None),
Owner::Comment(id) => (None, Some(*id), None, None),
Owner::ApNote(id) => (None, None, Some(*id), None),
Owner::ApRemoteNote(id) => (None, None, None, Some(*id)),
};
let active = ActiveModel {
uuid: Set(uuid),
owner_kind: Set(owner.kind().to_string()),
owner_id: Set(owner.id()),
filename: Set(filename.clone()),
content_type: Set(content_type.clone()),
size: Set(size),
remote_url: Set(None),
ticket_id: Set(ticket_id),
comment_id: Set(comment_id),
ap_note_id: Set(ap_note_id),
ap_remote_note_id: Set(ap_remote_note_id),
..Default::default()
};
// Insert row
match Entity::insert(active).exec(db).await {
Ok(result) => {
debug!(?uuid, ?owner, "Created attachment");
// Fetch and return the created model
Entity::find_by_id(result.last_insert_id)
.one(db)
.await?
.ok_or_else(|| {
sea_orm::DbErr::RecordNotFound("Attachment not found after insert".to_string())
})
}
Err(e) => {
error!(error = %e, ?uuid, "Failed to insert attachment row");
// Best-effort cleanup of file
let _ = files.delete(uuid, &content_type).await;
Err(e)
}
}
}
/// Delete a remote attachment (no file to clean up).
pub async fn delete_remote(
db: &DatabaseConnection,
attachment_id: i64,
) -> Result<(), sea_orm::DbErr> {
Entity::delete_by_id(attachment_id).exec(db).await?;
Ok(())
}
/// Delete an attachment, removing both the database row and the file.
///
/// Deletes the row first, then unlinks the file.
pub async fn delete(
db: &DatabaseConnection,
files: &FileStore,
attachment: &Model,
) -> Result<(), sea_orm::DbErr> {
let uuid = attachment.uuid;
let content_type = attachment.content_type.clone();
// Delete row first
Entity::delete_by_id(attachment.id).exec(db).await?;
debug!(?uuid, "Deleted attachment row");
// Best-effort file cleanup
if let Err(e) = files.delete(uuid, &content_type).await {
error!(error = %e, ?uuid, "Failed to delete attachment file");
}
Ok(())
}
/// List all attachments for a given owner.
pub async fn list_for(
db: &DatabaseConnection,
owner: &Owner,
) -> Result<Vec<Model>, sea_orm::DbErr> {
match owner {
Owner::Ticket(id) => {
Entity::find()
.filter(Column::TicketId.eq(*id))
.order_by_asc(Column::CreatedAt)
.all(db)
.await
}
Owner::Comment(id) => {
Entity::find()
.filter(Column::CommentId.eq(*id))
.order_by_asc(Column::CreatedAt)
.all(db)
.await
}
Owner::ApNote(id) => {
Entity::find()
.filter(Column::ApNoteId.eq(*id))
.order_by_asc(Column::CreatedAt)
.all(db)
.await
}
Owner::ApRemoteNote(id) => {
Entity::find()
.filter(Column::ApRemoteNoteId.eq(*id))
.order_by_asc(Column::CreatedAt)
.all(db)
.await
}
}
}
/// Find an attachment by UUID.
pub async fn find_by_uuid(
db: &DatabaseConnection,
uuid: Uuid,
) -> Result<Option<Model>, sea_orm::DbErr> {
Entity::find().filter(Column::Uuid.eq(uuid)).one(db).await
}
/// Count attachments for a given owner.
pub async fn count_for(db: &DatabaseConnection, owner: &Owner) -> Result<u64, sea_orm::DbErr> {
match owner {
Owner::Ticket(id) => {
Entity::find()
.filter(Column::TicketId.eq(*id))
.count(db)
.await
}
Owner::Comment(id) => {
Entity::find()
.filter(Column::CommentId.eq(*id))
.count(db)
.await
}
Owner::ApNote(id) => {
Entity::find()
.filter(Column::ApNoteId.eq(*id))
.count(db)
.await
}
Owner::ApRemoteNote(id) => {
Entity::find()
.filter(Column::ApRemoteNoteId.eq(*id))
.count(db)
.await
}
}
}
/// List attachments for multiple note IDs at once (for feed queries).
pub async fn list_for_multiple_notes(
db: &DatabaseConnection,
note_ids: &[i64],
) -> Result<Vec<Model>, sea_orm::DbErr> {
if note_ids.is_empty() {
return Ok(Vec::new());
}
Entity::find()
.filter(Column::OwnerKind.eq("ap_note"))
.filter(Column::OwnerId.is_in(note_ids.to_vec()))
.order_by_asc(Column::CreatedAt)
.all(db)
.await
}