🐸 Frogue(Fed)

~ololduck/Frogue

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
}