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