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