use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; #[derive(Debug, sqlx::FromRow)] pub struct Upload { pub id: Uuid, pub event_id: Uuid, pub user_id: Uuid, pub original_path: String, pub preview_path: Option, pub thumbnail_path: Option, pub mime_type: String, pub original_size_bytes: i64, pub caption: Option, pub compression_status: String, pub created_at: DateTime, pub deleted_at: Option>, } /// On-disk artifact paths returned by the soft-delete methods so the caller can /// unlink the files after the DB commit. #[derive(Debug, sqlx::FromRow)] pub struct DeletedPaths { pub original: String, pub preview: Option, pub thumbnail: Option, } #[derive(Debug, Serialize)] pub struct UploadDto { pub id: Uuid, pub user_id: Uuid, pub uploader_name: String, pub preview_url: Option, pub thumbnail_url: Option, /// Signed gateway URL for the full-resolution original. Always present. pub original_url: Option, pub mime_type: String, pub caption: Option, pub hashtags: Vec, pub like_count: i64, pub comment_count: i64, pub liked_by_me: bool, pub created_at: DateTime, } impl Upload { /// Generic over the executor so it can run inside the upload transaction /// (`&mut *tx`) alongside the quota-counter reservation, keeping the /// byte-counter and the row atomic (no quota leak on a mid-write crash). pub async fn create<'e, E>( executor: E, event_id: Uuid, user_id: Uuid, original_path: &str, mime_type: &str, original_size_bytes: i64, caption: Option<&str>, ) -> Result where E: sqlx::PgExecutor<'e>, { sqlx::query_as::<_, Self>( "INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *", ) .bind(event_id) .bind(user_id) .bind(original_path) .bind(mime_type) .bind(original_size_bytes) .bind(caption) .fetch_one(executor) .await } pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>( "SELECT * FROM upload WHERE id = $1 AND deleted_at IS NULL", ) .bind(id) .fetch_optional(pool) .await } /// Event-scoped lookup used by host endpoints so a host of event A cannot /// reach uploads belonging to event B. pub async fn find_by_id_and_event( pool: &PgPool, id: Uuid, event_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>( "SELECT * FROM upload WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL", ) .bind(id) .bind(event_id) .fetch_optional(pool) .await } pub async fn set_preview_path( pool: &PgPool, id: Uuid, preview_path: &str, ) -> Result<(), sqlx::Error> { sqlx::query("UPDATE upload SET preview_path = $2 WHERE id = $1") .bind(id) .bind(preview_path) .execute(pool) .await?; Ok(()) } pub async fn set_thumbnail_path( pool: &PgPool, id: Uuid, thumbnail_path: &str, ) -> Result<(), sqlx::Error> { sqlx::query("UPDATE upload SET thumbnail_path = $2 WHERE id = $1") .bind(id) .bind(thumbnail_path) .execute(pool) .await?; Ok(()) } /// Soft-deletes the upload and decrements the uploader's `total_upload_bytes`. /// Done in a single transaction so a crash between the two writes can't leave /// the quota counter pointing at bytes the user has already deleted (which would /// silently lock them out of future uploads). /// /// No-op if the row is already deleted — protects against a double-tap on the /// delete action double-decrementing the counter. /// /// Returns the artifact paths of the row that was deleted (`None` if nothing /// matched) so the caller can unlink the files after the commit. pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { let mut tx = pool.begin().await?; let row: Option<(Uuid, i64, String, Option, Option)> = sqlx::query_as( "UPDATE upload SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path", ) .bind(id) .fetch_optional(&mut *tx) .await?; let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row { sqlx::query( "UPDATE \"user\" SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2) WHERE id = $1", ) .bind(user_id) .bind(bytes) .execute(&mut *tx) .await?; Some(DeletedPaths { original, preview, thumbnail }) } else { None }; tx.commit().await?; Ok(paths) } /// Event-scoped variant of [`Self::soft_delete`]. Returns `None` if no row /// matched (already deleted, wrong event, or unknown id) so host handlers /// can return a clean 404, and the artifact paths otherwise. pub async fn soft_delete_in_event( pool: &PgPool, id: Uuid, event_id: Uuid, ) -> Result, sqlx::Error> { let mut tx = pool.begin().await?; let row: Option<(Uuid, i64, String, Option, Option)> = sqlx::query_as( "UPDATE upload SET deleted_at = NOW() WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path", ) .bind(id) .bind(event_id) .fetch_optional(&mut *tx) .await?; let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row { sqlx::query( "UPDATE \"user\" SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2) WHERE id = $1", ) .bind(user_id) .bind(bytes) .execute(&mut *tx) .await?; Some(DeletedPaths { original, preview, thumbnail }) } else { None }; tx.commit().await?; Ok(paths) } pub async fn update_caption( pool: &PgPool, id: Uuid, caption: Option<&str>, ) -> Result<(), sqlx::Error> { sqlx::query("UPDATE upload SET caption = $2 WHERE id = $1") .bind(id) .bind(caption) .execute(pool) .await?; Ok(()) } pub async fn set_compression_status( pool: &PgPool, id: Uuid, status: &str, ) -> Result<(), sqlx::Error> { sqlx::query("UPDATE upload SET compression_status = $2 WHERE id = $1") .bind(id) .bind(status) .execute(pool) .await?; Ok(()) } }