systemprompt_files/repository/content/
mod.rs1use 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}