catcolab_backend/
document.rs

1//! Procedures to create and manipulate documents.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use ts_rs::TS;
7use uuid::Uuid;
8
9use crate::{auth::PermissionLevel, user::UserSummary};
10
11use super::app::{AppCtx, AppError, AppState};
12
13/// Creates a new document ref with initial content.
14pub async fn new_ref(ctx: AppCtx, content: Value) -> Result<Uuid, AppError> {
15    let ref_id = Uuid::now_v7();
16
17    let mut transaction = ctx.state.db.begin().await?;
18
19    let user_id = ctx.user.map(|user| user.user_id);
20    let insert_ref = sqlx::query!(
21        "
22        WITH snapshot AS (
23            INSERT INTO snapshots(for_ref, content, last_updated)
24            VALUES ($1, $2, NOW())
25            RETURNING id
26        )
27        INSERT INTO refs(id, head, created)
28        VALUES ($1, (SELECT id FROM snapshot), NOW())
29        ",
30        ref_id,
31        content
32    );
33    insert_ref.execute(&mut *transaction).await?;
34
35    let insert_permission = sqlx::query!(
36        "
37        INSERT INTO permissions(subject, object, level)
38        VALUES ($1, $2, 'own')
39        ",
40        user_id,
41        ref_id,
42    );
43    insert_permission.execute(&mut *transaction).await?;
44
45    transaction.commit().await?;
46    Ok(ref_id)
47}
48
49/// Gets the content of the head snapshot for a document ref.
50pub async fn head_snapshot(state: AppState, ref_id: Uuid) -> Result<Value, AppError> {
51    let query = sqlx::query!(
52        "
53        SELECT content FROM snapshots
54        WHERE id = (SELECT head FROM refs WHERE id = $1)
55        ",
56        ref_id
57    );
58    Ok(query.fetch_one(&state.db).await?.content)
59}
60
61/// Saves the document by overwriting the snapshot at the current head.
62pub async fn autosave(state: AppState, data: RefContent) -> Result<(), AppError> {
63    let RefContent { ref_id, content } = data;
64    let query = sqlx::query!(
65        "
66        UPDATE snapshots
67        SET content = $2, last_updated = NOW()
68        WHERE id = (SELECT head FROM refs WHERE id = $1)
69        ",
70        ref_id,
71        content
72    );
73    query.execute(&state.db).await?;
74    Ok(())
75}
76
77/** Saves the document by replacing the head with a new snapshot.
78
79The snapshot at the previous head is *not* deleted.
80*/
81pub async fn save_snapshot(state: AppState, data: RefContent) -> Result<(), AppError> {
82    let RefContent { ref_id, content } = data;
83    let query = sqlx::query!(
84        "
85        WITH snapshot AS (
86            INSERT INTO snapshots(for_ref, content, last_updated)
87            VALUES ($1, $2, NOW())
88            RETURNING id
89        )
90        UPDATE refs
91        SET head = (SELECT id FROM snapshot)
92        WHERE id = $1
93        ",
94        ref_id,
95        content
96    );
97    query.execute(&state.db).await?;
98    Ok(())
99}
100
101/// Gets an Automerge document ID for the document ref.
102pub async fn doc_id(state: AppState, ref_id: Uuid) -> Result<String, AppError> {
103    let automerge_io = &state.automerge_io;
104    let ack = automerge_io.emit_with_ack::<Vec<Option<String>>>("get_doc", ref_id).unwrap();
105    let mut response = ack.await?;
106
107    let maybe_doc_id = response.data.pop().flatten();
108    if let Some(doc_id) = maybe_doc_id {
109        // If an Automerge doc handle for this ref already exists, return it.
110        Ok(doc_id)
111    } else {
112        // Otherwise, fetch the content from the database and create a new
113        // Automerge doc handle.
114        let content = head_snapshot(state.clone(), ref_id).await?;
115        let data = RefContent { ref_id, content };
116        let ack = automerge_io.emit_with_ack::<Vec<String>>("create_doc", data).unwrap();
117        let response = ack.await?;
118        Ok(response.data[0].to_string())
119    }
120}
121
122/// A document ref along with its content.
123#[derive(Debug, Serialize, Deserialize, TS)]
124pub struct RefContent {
125    #[serde(rename = "refId")]
126    pub ref_id: Uuid,
127    pub content: Value,
128}
129
130/// A subset of user relevant information about a ref. Used for showing
131/// users information on a variety of refs without having to load whole
132/// refs.
133#[derive(Clone, Debug, Serialize, Deserialize, TS)]
134pub struct RefStub {
135    pub name: String,
136    #[serde(rename = "typeName")]
137    pub type_name: String,
138    #[serde(rename = "refId")]
139    pub ref_id: Uuid,
140    // permission level that the current user has on this ref
141    #[serde(rename = "permissionLevel")]
142    pub permission_level: PermissionLevel,
143    pub owner: Option<UserSummary>,
144    #[serde(rename = "createdAt")]
145    pub created_at: DateTime<Utc>,
146}
147
148/// Parameters for filtering a search of refs
149#[derive(Clone, Debug, Serialize, Deserialize, TS)]
150pub struct RefQueryParams {
151    #[serde(rename = "ownerUsernameQuery")]
152    pub owner_username_query: Option<String>,
153    #[serde(rename = "refNameQuery")]
154    pub ref_name_query: Option<String>,
155    #[serde(rename = "searcherMinLevel")]
156    pub searcher_min_level: Option<PermissionLevel>,
157    #[serde(rename = "includePublicDocuments")]
158    pub include_public_documents: Option<bool>,
159    // TODO: add param for document type
160}
161
162/// Searches for `RefStub`s that the current user has permission to access,
163/// returning lightweight metadata about each matching ref
164pub async fn search_ref_stubs(
165    ctx: AppCtx,
166    search_params: RefQueryParams,
167) -> Result<Vec<RefStub>, AppError> {
168    let searcher_id = ctx.user.as_ref().map(|user| user.user_id.clone());
169
170    let min_level = search_params.searcher_min_level.unwrap_or(PermissionLevel::Read);
171
172    let results = sqlx::query!(
173        r#"
174        WITH effective_permissions AS (
175            /*
176              select at most one row per ref, the row is either:
177               - the searcher’s own permission, if it exists
178               - the public permission (subject IS NULL) when include_public_documents = TRUE and the
179                 searcher does not already have a row
180            */
181            SELECT DISTINCT ON (object)
182                   object,
183                   level
184            FROM   permissions
185            WHERE  (subject = $1)
186               OR  ($5 AND subject IS NULL)
187            ORDER BY object,
188                     (subject IS NOT NULL) DESC           -- prefer the user‑specific row
189        )
190        SELECT 
191            refs.id AS ref_id,
192            snapshots.content->>'name' AS name,
193            snapshots.content->>'type' AS type_name,
194            refs.created as created_at,
195            effective_permissions.level AS "permission_level: PermissionLevel",
196            owner.id AS "owner_id?",
197            owner.username AS "owner_username?",
198            owner.display_name AS "owner_display_name?"
199        FROM refs
200        JOIN snapshots ON snapshots.id = refs.head
201        JOIN effective_permissions ON effective_permissions.object = refs.id
202        JOIN permissions AS p_owner 
203            ON p_owner.object = refs.id AND p_owner.level = 'own'
204        LEFT JOIN users AS owner
205            ON owner.id = p_owner.subject
206        WHERE (
207            owner.username = $2
208            OR $2 IS NULL
209        )
210        AND (
211            snapshots.content->>'name' ILIKE '%' || $3 || '%'
212            OR $3 IS NULL
213        )
214        AND (
215            effective_permissions.level >= $4
216        )
217        LIMIT 100;
218        "#,
219        searcher_id,
220        search_params.owner_username_query,
221        search_params.ref_name_query,
222        min_level as PermissionLevel,
223        search_params.include_public_documents.unwrap_or(false),
224    )
225    .fetch_all(&ctx.state.db)
226    .await?;
227
228    // We can't use sqlx::query_as! because name and type_name can be null
229    let stubs = results
230        .into_iter()
231        .map(|row| RefStub {
232            ref_id: row.ref_id,
233            name: row.name.unwrap_or_else(|| "untitled".to_string()),
234            type_name: row.type_name.expect("type_name should never be null"),
235            permission_level: row.permission_level,
236            created_at: row.created_at,
237            owner: match row.owner_id {
238                Some(id) => Some(UserSummary {
239                    id,
240                    username: row.owner_username,
241                    display_name: row.owner_display_name,
242                }),
243                _ => None,
244            },
245        })
246        .collect();
247
248    Ok(stubs)
249}