systemprompt_files/repository/content/
mod.rs1use anyhow::{Context, Result};
2use chrono::Utc;
3use systemprompt_identifiers::{ContentId, ContextId, FileId, SessionId, TraceId, UserId};
4
5use super::file::FileRepository;
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 ) -> Result<ContentFile> {
16 let file_id_uuid =
17 uuid::Uuid::parse_str(file_id.as_str()).context("Invalid UUID for file id")?;
18 let now = Utc::now();
19 let content_id_str = content_id.as_str();
20
21 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 .context(format!(
39 "Failed to link file {file_id} to content {content_id}"
40 ))
41 }
42
43 pub async fn unlink_from_content(
44 &self,
45 content_id: &ContentId,
46 file_id: &FileId,
47 ) -> Result<()> {
48 let file_id_uuid =
49 uuid::Uuid::parse_str(file_id.as_str()).context("Invalid UUID for file id")?;
50 let content_id_str = content_id.as_str();
51
52 sqlx::query!(
53 r#"
54 DELETE FROM content_files
55 WHERE content_id = $1 AND file_id = $2
56 "#,
57 content_id_str,
58 file_id_uuid
59 )
60 .execute(self.pool.as_ref())
61 .await
62 .context(format!(
63 "Failed to unlink file {file_id} from content {content_id}"
64 ))?;
65
66 Ok(())
67 }
68
69 pub async fn list_files_by_content(
70 &self,
71 content_id: &ContentId,
72 ) -> Result<Vec<(File, ContentFile)>> {
73 let content_id_str = content_id.as_str();
74 let rows = sqlx::query!(
75 r#"
76 SELECT
77 f.id, f.path, f.public_url, f.mime_type, f.size_bytes, f.ai_content,
78 f.metadata, f.user_id, f.session_id, f.trace_id, f.context_id, f.created_at, f.updated_at, f.deleted_at,
79 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
80 FROM files f
81 INNER JOIN content_files cf ON cf.file_id = f.id
82 WHERE cf.content_id = $1 AND f.deleted_at IS NULL
83 ORDER BY cf.display_order ASC, cf.created_at ASC
84 "#,
85 content_id_str
86 )
87 .fetch_all(self.pool.as_ref())
88 .await
89 .context(format!("Failed to list files for content: {content_id}"))?;
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) -> Result<Option<File>> {
126 let content_id_str = content_id.as_str();
127 let featured_role = FileRole::Featured.as_str();
128 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 .context(format!(
146 "Failed to find featured image for content: {content_id}"
147 ))
148 }
149
150 pub async fn set_featured(&self, file_id: &FileId, content_id: &ContentId) -> Result<()> {
151 let file_id_uuid =
152 uuid::Uuid::parse_str(file_id.as_str()).context("Invalid UUID for file id")?;
153 let content_id_str = content_id.as_str();
154 let featured_role = FileRole::Featured.as_str();
155 let attachment_role = FileRole::Attachment.as_str();
156
157 let mut tx = self.pool.begin().await?;
158
159 sqlx::query!(
160 r#"
161 UPDATE content_files
162 SET role = $1
163 WHERE content_id = $2 AND role = $3
164 "#,
165 attachment_role,
166 content_id_str,
167 featured_role
168 )
169 .execute(&mut *tx)
170 .await?;
171
172 let result = sqlx::query!(
173 r#"
174 UPDATE content_files
175 SET role = $1
176 WHERE file_id = $2 AND content_id = $3
177 "#,
178 featured_role,
179 file_id_uuid,
180 content_id_str
181 )
182 .execute(&mut *tx)
183 .await?;
184
185 if result.rows_affected() == 0 {
186 return Err(anyhow::anyhow!(
187 "File {} is not linked to content {}",
188 file_id,
189 content_id
190 ));
191 }
192
193 tx.commit().await?;
194 Ok(())
195 }
196
197 pub async fn list_content_by_file(&self, file_id: &FileId) -> Result<Vec<ContentFile>> {
198 let file_id_uuid =
199 uuid::Uuid::parse_str(file_id.as_str()).context("Invalid UUID for file id")?;
200
201 sqlx::query_as!(
202 ContentFile,
203 r#"
204 SELECT id, content_id as "content_id: ContentId", file_id, role, display_order, created_at
205 FROM content_files
206 WHERE file_id = $1
207 ORDER BY created_at ASC
208 "#,
209 file_id_uuid
210 )
211 .fetch_all(self.pool.as_ref())
212 .await
213 .context(format!("Failed to list content for file: {file_id}"))
214 }
215}