aven-core 0.1.12

Core library for the Aven local-first task manager
use anyhow::{Result, bail};
use sqlx::{Row, SqliteConnection};

use crate::sync::wire::ChangeWire;

use super::payload::{AttachmentAddPayload, AttachmentDeletePayload};
use super::shared::task_field_workspace_id_payload;

pub(super) async fn add_attachment(conn: &mut SqliteConnection, change: &ChangeWire) -> Result<()> {
    let workspace_id = task_field_workspace_id_payload(conn, change).await?;
    ensure_attachment_task_exists(conn, workspace_id.as_str(), &change.entity_id).await?;
    let payload = AttachmentAddPayload::from_change(change)?;
    if let Some(row) =
        existing_attachment(conn, workspace_id.as_str(), &payload.attachment_id).await?
    {
        return ensure_same_attachment(&row, &change.entity_id, &payload);
    }

    sqlx::query(
        "INSERT INTO task_attachments(workspace_id, attachment_id, task_id, sha256, byte_size, media_type, filename, alt_text, width, height, created_at, created_by_change_id)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
    )
    .bind(&workspace_id)
    .bind(&payload.attachment_id)
    .bind(&change.entity_id)
    .bind(&payload.sha256)
    .bind(payload.byte_size)
    .bind(&payload.media_type)
    .bind(&payload.filename)
    .bind(&payload.alt_text)
    .bind(payload.width)
    .bind(payload.height)
    .bind(&payload.created_at)
    .bind(&change.change_id)
    .execute(&mut *conn)
    .await?;
    Ok(())
}

pub(super) async fn delete_attachment(
    conn: &mut SqliteConnection,
    change: &ChangeWire,
) -> Result<()> {
    let workspace_id = task_field_workspace_id_payload(conn, change).await?;
    let payload = AttachmentDeletePayload::from_change(change)?;
    let updated = sqlx::query(
        "UPDATE task_attachments
         SET deleted = 1,
             deleted_at = COALESCE(deleted_at, ?),
             deleted_by_change_id = COALESCE(deleted_by_change_id, ?)
         WHERE workspace_id = ? AND attachment_id = ? AND task_id = ?",
    )
    .bind(&payload.deleted_at)
    .bind(&change.change_id)
    .bind(&workspace_id)
    .bind(&payload.attachment_id)
    .bind(&change.entity_id)
    .execute(&mut *conn)
    .await?
    .rows_affected();

    if updated == 0
        && attachment_exists(conn, workspace_id.as_str(), &payload.attachment_id).await?
    {
        bail!(
            "error attachment-identity-conflict attachment_id={} task_id={}",
            payload.attachment_id,
            change.entity_id
        );
    }
    Ok(())
}

async fn ensure_attachment_task_exists(
    conn: &mut SqliteConnection,
    workspace_id: &str,
    task_id: &str,
) -> Result<()> {
    let count: i64 = sqlx::query_scalar::<_, i64>(
        "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
    )
    .bind(workspace_id)
    .bind(task_id)
    .fetch_one(&mut *conn)
    .await?;
    if count == 0 {
        bail!("error attachment-missing-task task_id={task_id}");
    }
    Ok(())
}

async fn existing_attachment(
    conn: &mut SqliteConnection,
    workspace_id: &str,
    attachment_id: &str,
) -> Result<Option<sqlx::sqlite::SqliteRow>> {
    Ok(sqlx::query(
        "SELECT task_id, sha256, byte_size, media_type, filename, alt_text, width, height, created_at
         FROM task_attachments
         WHERE workspace_id = ? AND attachment_id = ?",
    )
    .bind(workspace_id)
    .bind(attachment_id)
    .fetch_optional(&mut *conn)
    .await?)
}

fn ensure_same_attachment(
    row: &sqlx::sqlite::SqliteRow,
    task_id: &str,
    payload: &AttachmentAddPayload,
) -> Result<()> {
    let row_task_id: String = row.get("task_id");
    let row_sha256: String = row.get("sha256");
    let row_byte_size: i64 = row.get("byte_size");
    let row_media_type: String = row.get("media_type");
    let row_filename: Option<String> = row.get("filename");
    let row_alt_text: Option<String> = row.get("alt_text");
    let row_width: Option<i64> = row.get("width");
    let row_height: Option<i64> = row.get("height");
    let row_created_at: String = row.get("created_at");

    if row_task_id == task_id
        && row_sha256 == payload.sha256
        && row_byte_size == payload.byte_size
        && row_media_type == payload.media_type
        && row_filename == payload.filename
        && row_alt_text == payload.alt_text
        && row_width == payload.width
        && row_height == payload.height
        && row_created_at == payload.created_at
    {
        return Ok(());
    }

    bail!(
        "error attachment-identity-conflict attachment_id={} task_id={task_id}",
        payload.attachment_id
    )
}

async fn attachment_exists(
    conn: &mut SqliteConnection,
    workspace_id: &str,
    attachment_id: &str,
) -> Result<bool> {
    let count: i64 = sqlx::query_scalar::<_, i64>(
        "SELECT count(*) FROM task_attachments WHERE workspace_id = ? AND attachment_id = ?",
    )
    .bind(workspace_id)
    .bind(attachment_id)
    .fetch_one(&mut *conn)
    .await?;
    Ok(count > 0)
}