//! Low-level Postgres CRUD over `group_docs` (§11.6). A near-clone of //! [`crate::docs_repo`] keyed by the owning `group_id` instead of `app_id`. //! The `find` query is built by the shared [`crate::docs_repo::build_find_query`] //! (parameterized on table + owner column), so the security-sensitive filter //! SQL has a single source. Authorization, group resolution, value validation, //! and event policy live one layer up in `GroupDocsServiceImpl`. use async_trait::async_trait; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use chrono::{DateTime, Utc}; use picloud_shared::{DocId, DocRow, DocsListPage, GroupId}; use serde_json::Value; use sqlx::PgPool; use uuid::Uuid; use crate::docs_filter::DocsFilter; use crate::docs_repo::{build_find_query, row_to_doc, DocsRepoError}; #[derive(Debug, thiserror::Error)] pub enum GroupDocsRepoError { #[error("database error: {0}")] Db(#[from] sqlx::Error), #[error("invalid pagination cursor")] InvalidCursor, } impl From for GroupDocsRepoError { fn from(e: DocsRepoError) -> Self { match e { DocsRepoError::Db(e) => Self::Db(e), DocsRepoError::InvalidCursor => Self::InvalidCursor, } } } #[async_trait] pub trait GroupDocsRepo: Send + Sync { async fn create( &self, group_id: GroupId, collection: &str, data: Value, ) -> Result; async fn get( &self, group_id: GroupId, collection: &str, id: DocId, ) -> Result, GroupDocsRepoError>; async fn find( &self, group_id: GroupId, collection: &str, filter: &DocsFilter, ) -> Result, GroupDocsRepoError>; /// Returns the previous data (for the would-be event), `None` if missing. async fn update( &self, group_id: GroupId, collection: &str, id: DocId, data: Value, ) -> Result, GroupDocsRepoError>; async fn delete( &self, group_id: GroupId, collection: &str, id: DocId, ) -> Result, GroupDocsRepoError>; async fn list( &self, group_id: GroupId, collection: &str, cursor: Option<&str>, limit: u32, ) -> Result; /// §11.6 quota: total doc count across the group's shared-docs collections. /// Default `Ok(0)` so non-Postgres impls skip the quota check. async fn count_rows(&self, group_id: GroupId) -> Result { let _ = group_id; Ok(0) } } pub struct PostgresGroupDocsRepo { pool: PgPool, } impl PostgresGroupDocsRepo { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } const DOCS_LIST_MAX_LIMIT: u32 = 1_000; const DOCS_LIST_DEFAULT_LIMIT: u32 = 100; #[async_trait] impl GroupDocsRepo for PostgresGroupDocsRepo { async fn create( &self, group_id: GroupId, collection: &str, data: Value, ) -> Result { let id = Uuid::new_v4(); let row: (DateTime, DateTime) = sqlx::query_as( "INSERT INTO group_docs (group_id, collection, id, data) \ VALUES ($1, $2, $3, $4) \ RETURNING created_at, updated_at", ) .bind(group_id.into_inner()) .bind(collection) .bind(id) .bind(&data) .fetch_one(&self.pool) .await?; Ok(DocRow { id, data, created_at: row.0, updated_at: row.1, }) } async fn get( &self, group_id: GroupId, collection: &str, id: DocId, ) -> Result, GroupDocsRepoError> { let row: Option<(Value, DateTime, DateTime)> = sqlx::query_as( "SELECT data, created_at, updated_at FROM group_docs \ WHERE group_id = $1 AND collection = $2 AND id = $3", ) .bind(group_id.into_inner()) .bind(collection) .bind(id) .fetch_optional(&self.pool) .await?; Ok(row.map(|(data, created_at, updated_at)| DocRow { id, data, created_at, updated_at, })) } async fn find( &self, group_id: GroupId, collection: &str, filter: &DocsFilter, ) -> Result, GroupDocsRepoError> { let mut qb = build_find_query( "group_docs", "group_id", group_id.into_inner(), collection, filter, ); let rows = qb.build().fetch_all(&self.pool).await?; rows.into_iter() .map(row_to_doc) .collect::, _>>() .map_err(Into::into) } async fn update( &self, group_id: GroupId, collection: &str, id: DocId, data: Value, ) -> Result, GroupDocsRepoError> { let row: Option<(Option,)> = sqlx::query_as( "WITH prev AS ( \ SELECT data FROM group_docs \ WHERE group_id = $1 AND collection = $2 AND id = $3 \ ), \ updated AS ( \ UPDATE group_docs SET data = $4, updated_at = NOW() \ WHERE group_id = $1 AND collection = $2 AND id = $3 \ RETURNING 1 \ ) \ SELECT (SELECT data FROM prev) FROM updated", ) .bind(group_id.into_inner()) .bind(collection) .bind(id) .bind(&data) .fetch_optional(&self.pool) .await?; Ok(row.and_then(|(v,)| v)) } async fn delete( &self, group_id: GroupId, collection: &str, id: DocId, ) -> Result, GroupDocsRepoError> { let row: Option<(Value,)> = sqlx::query_as( "DELETE FROM group_docs \ WHERE group_id = $1 AND collection = $2 AND id = $3 \ RETURNING data", ) .bind(group_id.into_inner()) .bind(collection) .bind(id) .fetch_optional(&self.pool) .await?; Ok(row.map(|(v,)| v)) } async fn list( &self, group_id: GroupId, collection: &str, cursor: Option<&str>, limit: u32, ) -> Result { let limit = if limit == 0 { DOCS_LIST_DEFAULT_LIMIT } else { limit.min(DOCS_LIST_MAX_LIMIT) }; let last_id = match cursor { Some(c) => Some(decode_cursor(c)?), None => None, }; let take = i64::from(limit) + 1; let rows: Vec<(Uuid, Value, DateTime, DateTime)> = sqlx::query_as( "SELECT id, data, created_at, updated_at FROM group_docs \ WHERE group_id = $1 AND collection = $2 \ AND ($3::uuid IS NULL OR id > $3) \ ORDER BY id ASC \ LIMIT $4", ) .bind(group_id.into_inner()) .bind(collection) .bind(last_id) .bind(take) .fetch_all(&self.pool) .await?; let mut docs: Vec = rows .into_iter() .map(|(id, data, created_at, updated_at)| DocRow { id, data, created_at, updated_at, }) .collect(); let next_cursor = if docs.len() > limit as usize { docs.truncate(limit as usize); docs.last().map(|d| encode_cursor(&d.id)) } else { None }; Ok(DocsListPage { docs, next_cursor }) } async fn count_rows(&self, group_id: GroupId) -> Result { let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM group_docs WHERE group_id = $1") .bind(group_id.into_inner()) .fetch_one(&self.pool) .await?; Ok(u64::try_from(n).unwrap_or(0)) } } fn encode_cursor(last_id: &Uuid) -> String { URL_SAFE_NO_PAD.encode(last_id.as_bytes()) } fn decode_cursor(cursor: &str) -> Result { let bytes = URL_SAFE_NO_PAD .decode(cursor) .map_err(|_| GroupDocsRepoError::InvalidCursor)?; let arr: [u8; 16] = bytes .as_slice() .try_into() .map_err(|_| GroupDocsRepoError::InvalidCursor)?; Ok(Uuid::from_bytes(arr)) }