Skip to main content

ai_crew_sync/store/
notes.rs

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    // Keep an append-only trail so a bad overwrite is recoverable.
97    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    // `scope = None` means "every scope", so it is not normalised to 'global'.
145    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}