Skip to main content

systemprompt_files/repository/content/
mod.rs

1use chrono::Utc;
2use systemprompt_identifiers::{ContentId, ContextId, FileId, SessionId, TraceId, UserId};
3
4use super::file::FileRepository;
5use crate::error::{FilesError, FilesResult};
6use crate::models::{ContentFile, File, FileRole};
7
8impl FileRepository {
9    pub async fn link_to_content(
10        &self,
11        content_id: &ContentId,
12        file_id: &FileId,
13        role: FileRole,
14        display_order: i32,
15    ) -> FilesResult<ContentFile> {
16        let file_id_uuid = uuid::Uuid::parse_str(file_id.as_str())
17            .map_err(|e| FilesError::Validation(format!("Invalid UUID for file id: {e}")))?;
18        let now = Utc::now();
19        let content_id_str = content_id.as_str();
20
21        let result = sqlx::query_as!(
22            ContentFile,
23            r#"
24            INSERT INTO content_files (content_id, file_id, role, display_order, created_at)
25            VALUES ($1, $2, $3, $4, $5)
26            ON CONFLICT (content_id, file_id, role) DO UPDATE
27            SET display_order = $4
28            RETURNING id, content_id as "content_id: ContentId", file_id, role, display_order, created_at
29            "#,
30            content_id_str,
31            file_id_uuid,
32            role.as_str(),
33            display_order,
34            now
35        )
36        .fetch_one(self.pool.as_ref())
37        .await?;
38
39        Ok(result)
40    }
41
42    pub async fn unlink_from_content(
43        &self,
44        content_id: &ContentId,
45        file_id: &FileId,
46    ) -> FilesResult<()> {
47        let file_id_uuid = uuid::Uuid::parse_str(file_id.as_str())
48            .map_err(|e| FilesError::Validation(format!("Invalid UUID for file id: {e}")))?;
49        let content_id_str = content_id.as_str();
50
51        sqlx::query!(
52            r#"
53            DELETE FROM content_files
54            WHERE content_id = $1 AND file_id = $2
55            "#,
56            content_id_str,
57            file_id_uuid
58        )
59        .execute(self.pool.as_ref())
60        .await?;
61
62        Ok(())
63    }
64
65    pub async fn list_files_by_content(
66        &self,
67        content_id: &ContentId,
68    ) -> FilesResult<Vec<(File, ContentFile)>> {
69        let content_id_str = content_id.as_str();
70        let rows = sqlx::query!(
71            r#"
72            SELECT
73                f.id, f.path, f.public_url, f.mime_type, f.size_bytes, f.ai_content,
74                f.metadata, f.user_id, f.session_id, f.trace_id, f.context_id, f.created_at, f.updated_at, f.deleted_at,
75                cf.id as cf_id, cf.content_id, cf.file_id as cf_file_id, cf.role, cf.display_order, cf.created_at as cf_created_at
76            FROM files f
77            INNER JOIN content_files cf ON cf.file_id = f.id
78            WHERE cf.content_id = $1 AND f.deleted_at IS NULL
79            ORDER BY cf.display_order ASC, cf.created_at ASC
80            "#,
81            content_id_str
82        )
83        .fetch_all(self.pool.as_ref())
84        .await?;
85
86        Ok(rows
87            .into_iter()
88            .map(|row| {
89                let file = File {
90                    id: row.id,
91                    path: row.path,
92                    public_url: row.public_url,
93                    mime_type: row.mime_type,
94                    size_bytes: row.size_bytes,
95                    ai_content: row.ai_content,
96                    metadata: row.metadata,
97                    user_id: row.user_id.map(UserId::new),
98                    session_id: row.session_id.map(SessionId::new),
99                    trace_id: row.trace_id.map(TraceId::new),
100                    context_id: row.context_id.map(ContextId::new),
101                    created_at: row.created_at,
102                    updated_at: row.updated_at,
103                    deleted_at: row.deleted_at,
104                };
105
106                let content_file = ContentFile {
107                    id: row.cf_id,
108                    content_id: ContentId::new(row.content_id),
109                    file_id: row.cf_file_id,
110                    role: row.role,
111                    display_order: row.display_order,
112                    created_at: row.cf_created_at,
113                };
114
115                (file, content_file)
116            })
117            .collect())
118    }
119
120    pub async fn find_featured_image(&self, content_id: &ContentId) -> FilesResult<Option<File>> {
121        let content_id_str = content_id.as_str();
122        let featured_role = FileRole::Featured.as_str();
123        let result = sqlx::query_as!(
124            File,
125            r#"
126            SELECT f.id, f.path, f.public_url, f.mime_type, f.size_bytes, f.ai_content,
127                   f.metadata, f.user_id as "user_id: UserId", f.session_id as "session_id: SessionId", f.trace_id as "trace_id: TraceId", f.context_id as "context_id: ContextId", f.created_at, f.updated_at, f.deleted_at
128            FROM files f
129            INNER JOIN content_files cf ON cf.file_id = f.id
130            WHERE cf.content_id = $1
131              AND cf.role = $2
132              AND f.deleted_at IS NULL
133            LIMIT 1
134            "#,
135            content_id_str,
136            featured_role
137        )
138        .fetch_optional(self.pool.as_ref())
139        .await?;
140
141        Ok(result)
142    }
143
144    pub async fn set_featured(&self, file_id: &FileId, content_id: &ContentId) -> FilesResult<()> {
145        let file_id_uuid = uuid::Uuid::parse_str(file_id.as_str())
146            .map_err(|e| FilesError::Validation(format!("Invalid UUID for file id: {e}")))?;
147        let content_id_str = content_id.as_str();
148        let featured_role = FileRole::Featured.as_str();
149        let attachment_role = FileRole::Attachment.as_str();
150
151        let mut tx = self.pool.begin().await?;
152
153        sqlx::query!(
154            r#"
155            UPDATE content_files
156            SET role = $1
157            WHERE content_id = $2 AND role = $3
158            "#,
159            attachment_role,
160            content_id_str,
161            featured_role
162        )
163        .execute(&mut *tx)
164        .await?;
165
166        let result = sqlx::query!(
167            r#"
168            UPDATE content_files
169            SET role = $1
170            WHERE file_id = $2 AND content_id = $3
171            "#,
172            featured_role,
173            file_id_uuid,
174            content_id_str
175        )
176        .execute(&mut *tx)
177        .await?;
178
179        if result.rows_affected() == 0 {
180            return Err(FilesError::NotFound(format!(
181                "File {file_id} is not linked to content {content_id}"
182            )));
183        }
184
185        tx.commit().await?;
186        Ok(())
187    }
188
189    pub async fn list_content_by_file(&self, file_id: &FileId) -> FilesResult<Vec<ContentFile>> {
190        let file_id_uuid = uuid::Uuid::parse_str(file_id.as_str())
191            .map_err(|e| FilesError::Validation(format!("Invalid UUID for file id: {e}")))?;
192
193        let result = sqlx::query_as!(
194            ContentFile,
195            r#"
196            SELECT id, content_id as "content_id: ContentId", file_id, role, display_order, created_at
197            FROM content_files
198            WHERE file_id = $1
199            ORDER BY created_at ASC
200            "#,
201            file_id_uuid
202        )
203        .fetch_all(self.pool.as_ref())
204        .await?;
205
206        Ok(result)
207    }
208}