forge_core_db/models/
image.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sqlx::{FromRow, SqlitePool};
4use ts_rs_forge::TS;
5use uuid::Uuid;
6
7#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]
8pub struct Image {
9    pub id: Uuid,
10    pub file_path: String, // relative path within cache/images/
11    pub original_name: String,
12    pub mime_type: Option<String>,
13    pub size_bytes: i64,
14    pub hash: String, // SHA256 hash for deduplication
15    pub created_at: DateTime<Utc>,
16    pub updated_at: DateTime<Utc>,
17}
18
19#[derive(Debug, Deserialize, TS)]
20pub struct CreateImage {
21    pub file_path: String,
22    pub original_name: String,
23    pub mime_type: Option<String>,
24    pub size_bytes: i64,
25    pub hash: String,
26}
27
28#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]
29pub struct TaskImage {
30    pub id: Uuid,
31    pub task_id: Uuid,
32    pub image_id: Uuid,
33    pub created_at: DateTime<Utc>,
34}
35
36#[derive(Debug, Deserialize, TS)]
37pub struct CreateTaskImage {
38    pub task_id: Uuid,
39    pub image_id: Uuid,
40}
41
42impl Image {
43    pub async fn create(pool: &SqlitePool, data: &CreateImage) -> Result<Self, sqlx::Error> {
44        let id = Uuid::new_v4();
45        sqlx::query_as!(
46            Image,
47            r#"INSERT INTO images (id, file_path, original_name, mime_type, size_bytes, hash)
48               VALUES ($1, $2, $3, $4, $5, $6)
49               RETURNING id as "id!: Uuid", 
50                         file_path as "file_path!", 
51                         original_name as "original_name!", 
52                         mime_type,
53                         size_bytes as "size_bytes!",
54                         hash as "hash!",
55                         created_at as "created_at!: DateTime<Utc>", 
56                         updated_at as "updated_at!: DateTime<Utc>""#,
57            id,
58            data.file_path,
59            data.original_name,
60            data.mime_type,
61            data.size_bytes,
62            data.hash,
63        )
64        .fetch_one(pool)
65        .await
66    }
67
68    pub async fn find_by_hash(pool: &SqlitePool, hash: &str) -> Result<Option<Self>, sqlx::Error> {
69        sqlx::query_as!(
70            Image,
71            r#"SELECT id as "id!: Uuid",
72                      file_path as "file_path!",
73                      original_name as "original_name!",
74                      mime_type,
75                      size_bytes as "size_bytes!",
76                      hash as "hash!",
77                      created_at as "created_at!: DateTime<Utc>",
78                      updated_at as "updated_at!: DateTime<Utc>"
79               FROM images
80               WHERE hash = $1"#,
81            hash
82        )
83        .fetch_optional(pool)
84        .await
85    }
86
87    pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
88        sqlx::query_as!(
89            Image,
90            r#"SELECT id as "id!: Uuid",
91                      file_path as "file_path!",
92                      original_name as "original_name!",
93                      mime_type,
94                      size_bytes as "size_bytes!",
95                      hash as "hash!",
96                      created_at as "created_at!: DateTime<Utc>",
97                      updated_at as "updated_at!: DateTime<Utc>"
98               FROM images
99               WHERE id = $1"#,
100            id
101        )
102        .fetch_optional(pool)
103        .await
104    }
105
106    pub async fn find_by_task_id(
107        pool: &SqlitePool,
108        task_id: Uuid,
109    ) -> Result<Vec<Self>, sqlx::Error> {
110        sqlx::query_as!(
111            Image,
112            r#"SELECT i.id as "id!: Uuid",
113                      i.file_path as "file_path!",
114                      i.original_name as "original_name!",
115                      i.mime_type,
116                      i.size_bytes as "size_bytes!",
117                      i.hash as "hash!",
118                      i.created_at as "created_at!: DateTime<Utc>",
119                      i.updated_at as "updated_at!: DateTime<Utc>"
120               FROM images i
121               JOIN task_images ti ON i.id = ti.image_id
122               WHERE ti.task_id = $1
123               ORDER BY ti.created_at"#,
124            task_id
125        )
126        .fetch_all(pool)
127        .await
128    }
129
130    pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<(), sqlx::Error> {
131        sqlx::query!(r#"DELETE FROM images WHERE id = $1"#, id)
132            .execute(pool)
133            .await?;
134        Ok(())
135    }
136
137    pub async fn find_orphaned_images(pool: &SqlitePool) -> Result<Vec<Self>, sqlx::Error> {
138        sqlx::query_as!(
139            Image,
140            r#"SELECT i.id as "id!: Uuid",
141                      i.file_path as "file_path!",
142                      i.original_name as "original_name!",
143                      i.mime_type,
144                      i.size_bytes as "size_bytes!",
145                      i.hash as "hash!",
146                      i.created_at as "created_at!: DateTime<Utc>",
147                      i.updated_at as "updated_at!: DateTime<Utc>"
148               FROM images i
149               LEFT JOIN task_images ti ON i.id = ti.image_id
150               WHERE ti.task_id IS NULL"#
151        )
152        .fetch_all(pool)
153        .await
154    }
155}
156
157impl TaskImage {
158    pub async fn create(pool: &SqlitePool, data: &CreateTaskImage) -> Result<Self, sqlx::Error> {
159        let id = Uuid::new_v4();
160        sqlx::query_as!(
161            TaskImage,
162            r#"INSERT INTO task_images (id, task_id, image_id)
163               VALUES ($1, $2, $3)
164               RETURNING id as "id!: Uuid",
165                         task_id as "task_id!: Uuid",
166                         image_id as "image_id!: Uuid", 
167                         created_at as "created_at!: DateTime<Utc>""#,
168            id,
169            data.task_id,
170            data.image_id,
171        )
172        .fetch_one(pool)
173        .await
174    }
175
176    pub async fn associate_many(
177        pool: &SqlitePool,
178        task_id: Uuid,
179        image_ids: &[Uuid],
180    ) -> Result<(), sqlx::Error> {
181        for &image_id in image_ids {
182            let task_image = CreateTaskImage { task_id, image_id };
183            TaskImage::create(pool, &task_image).await?;
184        }
185        Ok(())
186    }
187
188    /// Associate multiple images with a task, skipping duplicates.
189    pub async fn associate_many_dedup(
190        pool: &SqlitePool,
191        task_id: Uuid,
192        image_ids: &[Uuid],
193    ) -> Result<(), sqlx::Error> {
194        for &image_id in image_ids {
195            let id = Uuid::new_v4();
196            sqlx::query!(
197                r#"INSERT INTO task_images (id, task_id, image_id)
198                   SELECT $1, $2, $3
199                   WHERE NOT EXISTS (
200                       SELECT 1 FROM task_images WHERE task_id = $2 AND image_id = $3
201                   )"#,
202                id,
203                task_id,
204                image_id
205            )
206            .execute(pool)
207            .await?;
208        }
209        Ok(())
210    }
211
212    pub async fn delete_by_task_id(pool: &SqlitePool, task_id: Uuid) -> Result<(), sqlx::Error> {
213        sqlx::query!(r#"DELETE FROM task_images WHERE task_id = $1"#, task_id)
214            .execute(pool)
215            .await?;
216        Ok(())
217    }
218}