1use sqlx::PgPool;
2use uuid::Uuid;
3
4use crate::{
5 auth::AuthCtx,
6 error::{BusError, BusResult},
7 model::{Ack, NoteInfo, NoteList, NoteRef, ts},
8};
9
10const MAX_VALUE_BYTES: usize = 256 * 1024;
11const MAX_LIMIT: i64 = 200;
12
13#[derive(sqlx::FromRow)]
14struct NoteRow {
15 scope: String,
16 key: String,
17 value: String,
18 tags: Vec<String>,
19 updated_by: Option<String>,
20 updated_at: chrono::DateTime<chrono::Utc>,
21}
22
23impl From<NoteRow> for NoteInfo {
24 fn from(r: NoteRow) -> Self {
25 NoteInfo {
26 scope: r.scope,
27 key: r.key,
28 value: r.value,
29 tags: r.tags,
30 updated_by: r.updated_by,
31 updated_at: ts(r.updated_at),
32 }
33 }
34}
35
36const NOTE_SELECT: &str = r#"
37 SELECT n.scope, n.key, n.value, n.tags, a.name AS updated_by, n.updated_at
38 FROM notes n
39 LEFT JOIN agents a ON a.id = n.updated_by
40"#;
41
42fn normalize_scope(scope: Option<String>) -> String {
43 scope
44 .map(|s| s.trim().to_lowercase())
45 .filter(|s| !s.is_empty())
46 .unwrap_or_else(|| "global".into())
47}
48
49pub struct SetInput {
50 pub scope: Option<String>,
51 pub key: String,
52 pub value: String,
53 pub tags: Option<Vec<String>>,
54}
55
56pub async fn set_note(pool: &PgPool, auth: &AuthCtx, input: SetInput) -> BusResult<NoteInfo> {
57 let scope = normalize_scope(input.scope);
58 let key = input.key.trim().to_owned();
59 if key.is_empty() {
60 return Err(BusError::invalid("note key cannot be empty"));
61 }
62 if input.value.len() > MAX_VALUE_BYTES {
63 return Err(BusError::invalid(format!(
64 "note value is limited to {MAX_VALUE_BYTES} bytes"
65 )));
66 }
67 let tags: Vec<String> = input
68 .tags
69 .unwrap_or_default()
70 .into_iter()
71 .map(|t| t.trim().to_lowercase())
72 .filter(|t| !t.is_empty())
73 .collect();
74
75 let (id,): (Uuid,) = sqlx::query_as(
76 r#"
77 INSERT INTO notes (team_id, scope, key, value, tags, updated_by)
78 VALUES ($1, $2, $3, $4, $5, $6)
79 ON CONFLICT (team_id, scope, key) DO UPDATE SET
80 value = EXCLUDED.value,
81 tags = EXCLUDED.tags,
82 updated_by = EXCLUDED.updated_by,
83 updated_at = now()
84 RETURNING id
85 "#,
86 )
87 .bind(auth.team_id)
88 .bind(&scope)
89 .bind(&key)
90 .bind(&input.value)
91 .bind(&tags)
92 .bind(auth.agent_id)
93 .fetch_one(pool)
94 .await?;
95
96 sqlx::query("INSERT INTO note_revisions (note_id, value, updated_by) VALUES ($1, $2, $3)")
98 .bind(id)
99 .bind(&input.value)
100 .bind(auth.agent_id)
101 .execute(pool)
102 .await?;
103
104 get_note(pool, auth, Some(scope), &key)
105 .await?
106 .note
107 .ok_or_else(|| BusError::not_found("note just written"))
108}
109
110pub async fn get_note(
111 pool: &PgPool,
112 auth: &AuthCtx,
113 scope: Option<String>,
114 key: &str,
115) -> BusResult<NoteRef> {
116 let scope = normalize_scope(scope);
117 let key = key.trim().to_owned();
118
119 let row: Option<NoteRow> = sqlx::query_as(&format!(
120 "{NOTE_SELECT} WHERE n.team_id = $1 AND n.scope = $2 AND n.key = $3"
121 ))
122 .bind(auth.team_id)
123 .bind(&scope)
124 .bind(&key)
125 .fetch_optional(pool)
126 .await?;
127
128 Ok(NoteRef {
129 scope,
130 key,
131 found: row.is_some(),
132 note: row.map(Into::into),
133 })
134}
135
136pub async fn list_notes(
137 pool: &PgPool,
138 auth: &AuthCtx,
139 scope: Option<String>,
140 tag: Option<String>,
141 limit: i64,
142) -> BusResult<NoteList> {
143 let limit = limit.clamp(1, MAX_LIMIT);
144 let scope = scope
146 .map(|s| s.trim().to_lowercase())
147 .filter(|s| !s.is_empty());
148 let tag = tag
149 .map(|t| t.trim().to_lowercase())
150 .filter(|t| !t.is_empty());
151
152 let rows: Vec<NoteRow> = sqlx::query_as(&format!(
153 r#"{NOTE_SELECT}
154 WHERE n.team_id = $1
155 AND ($2::text IS NULL OR n.scope = $2)
156 AND ($3::text IS NULL OR $3 = ANY(n.tags))
157 ORDER BY n.scope, n.key
158 LIMIT $4"#
159 ))
160 .bind(auth.team_id)
161 .bind(scope.as_deref())
162 .bind(tag.as_deref())
163 .bind(limit)
164 .fetch_all(pool)
165 .await?;
166
167 Ok(NoteList {
168 notes: rows.into_iter().map(Into::into).collect(),
169 })
170}
171
172pub async fn search_notes(
173 pool: &PgPool,
174 auth: &AuthCtx,
175 query: &str,
176 scope: Option<String>,
177 limit: i64,
178) -> BusResult<NoteList> {
179 let query = query.trim();
180 if query.is_empty() {
181 return Err(BusError::invalid("search query cannot be empty"));
182 }
183 let limit = limit.clamp(1, MAX_LIMIT);
184 let scope = scope
185 .map(|s| s.trim().to_lowercase())
186 .filter(|s| !s.is_empty());
187
188 let rows: Vec<NoteRow> = sqlx::query_as(&format!(
189 r#"{NOTE_SELECT}
190 WHERE n.team_id = $1
191 AND ($2::text IS NULL OR n.scope = $2)
192 AND to_tsvector('simple', n.key || ' ' || n.value)
193 @@ plainto_tsquery('simple', $3)
194 ORDER BY n.updated_at DESC
195 LIMIT $4"#
196 ))
197 .bind(auth.team_id)
198 .bind(scope.as_deref())
199 .bind(query)
200 .bind(limit)
201 .fetch_all(pool)
202 .await?;
203
204 Ok(NoteList {
205 notes: rows.into_iter().map(Into::into).collect(),
206 })
207}
208
209pub async fn delete_note(
210 pool: &PgPool,
211 auth: &AuthCtx,
212 scope: Option<String>,
213 key: &str,
214) -> BusResult<Ack> {
215 let scope = normalize_scope(scope);
216 let key = key.trim();
217
218 let res = sqlx::query("DELETE FROM notes WHERE team_id = $1 AND scope = $2 AND key = $3")
219 .bind(auth.team_id)
220 .bind(&scope)
221 .bind(key)
222 .execute(pool)
223 .await?;
224
225 Ok(Ack {
226 ok: res.rows_affected() > 0,
227 detail: if res.rows_affected() > 0 {
228 format!("deleted note {scope}/{key}")
229 } else {
230 format!("no note at {scope}/{key}")
231 },
232 })
233}