From 09b03633e35467c909d641d601efb4f941c5dc38 Mon Sep 17 00:00:00 2001 From: Jason Moggridge Date: Mon, 17 Feb 2025 12:32:25 -0500 Subject: [PATCH] Add backend for querying refs --- packages/backend/pkg/src/RefStub.ts | 7 ++ packages/backend/pkg/src/index.ts | 4 +- packages/backend/src/document.rs | 140 ++++++++++++++++++++++++++++ packages/backend/src/rpc.rs | 18 ++++ packages/backend/src/user.rs | 10 ++ 5 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 packages/backend/pkg/src/RefStub.ts diff --git a/packages/backend/pkg/src/RefStub.ts b/packages/backend/pkg/src/RefStub.ts new file mode 100644 index 00000000..c832e704 --- /dev/null +++ b/packages/backend/pkg/src/RefStub.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Information to allow the display and referencing of documents without + * loading the documents contents + */ +export type RefStub = { name: string, ref_id: string, }; diff --git a/packages/backend/pkg/src/index.ts b/packages/backend/pkg/src/index.ts index 38f18996..a4f281e8 100644 --- a/packages/backend/pkg/src/index.ts +++ b/packages/backend/pkg/src/index.ts @@ -22,6 +22,7 @@ import type { NewPermissions } from "./NewPermissions.ts"; import type { UserSummary } from "./UserSummary.ts"; import type { UsernameStatus } from "./UsernameStatus.ts"; import type { UserProfile } from "./UserProfile.ts"; +import type { RefStub } from "./RefStub.ts"; export type { RpcResult } from "./RpcResult.ts"; export type { JsonValue } from "./serde_json/JsonValue.ts"; @@ -35,5 +36,6 @@ export type { NewPermissions } from "./NewPermissions.ts"; export type { UserSummary } from "./UserSummary.ts"; export type { UsernameStatus } from "./UsernameStatus.ts"; export type { UserProfile } from "./UserProfile.ts"; +export type { RefStub } from "./RefStub.ts"; -export type QubitServer = { new_ref: Mutation<[content: JsonValue, ], RpcResult>, get_doc: Query<[ref_id: string, ], RpcResult>, head_snapshot: Query<[ref_id: string, ], RpcResult>, save_snapshot: Mutation<[data: RefContent, ], RpcResult>, get_permissions: Query<[ref_id: string, ], RpcResult>, set_permissions: Mutation<[ref_id: string, new: NewPermissions, ], RpcResult>, sign_up_or_sign_in: Mutation<[], RpcResult>, user_by_username: Query<[username: string, ], RpcResult>, username_status: Query<[username: string, ], RpcResult>, get_active_user_profile: Query<[], RpcResult>, set_active_user_profile: Mutation<[user: UserProfile, ], RpcResult> }; \ No newline at end of file +export type QubitServer = { new_ref: Mutation<[content: JsonValue, ], RpcResult>, get_doc: Query<[ref_id: string, ], RpcResult>, head_snapshot: Query<[ref_id: string, ], RpcResult>, save_snapshot: Mutation<[data: RefContent, ], RpcResult>, get_permissions: Query<[ref_id: string, ], RpcResult>, set_permissions: Mutation<[ref_id: string, new: NewPermissions, ], RpcResult>, sign_up_or_sign_in: Mutation<[], RpcResult>, user_by_username: Query<[username: string, ], RpcResult>, username_status: Query<[username: string, ], RpcResult>, get_active_user_profile: Query<[], RpcResult>, set_active_user_profile: Mutation<[user: UserProfile, ], RpcResult> }; diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index 4bf63b18..53965d52 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -123,3 +123,143 @@ pub struct RefContent { pub ref_id: Uuid, pub content: Value, } + +/// A subset of user relevant information about a ref. Used for showing +/// users information on a variety of refs without having to load whole +/// refs. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +pub struct RefStub { + pub name: String, + pub ref_id: Uuid, + // TODO: get the types for these fields serializeable + // pub permission_level: PermissionLevel, + // pub created_at + // pub last_updated +} + +/// Parameters for filtering a search of refs +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +pub struct RefQueryParams { + pub owner_username_query: Option, + pub ref_name_query: Option, + // TODO: add param for document type +} + +/// Gets a vec of user relevant informations about refs that match the query parameters +pub async fn get_ref_stubs( + ctx: AppCtx, + search_params: RefQueryParams, +) -> Result, AppError> { + let searcher_id = ctx.user.as_ref().map(|user| user.user_id.clone()); + + // selects the ref id and ref name (from the most recent snapshot of that ref) for all refs that a user (the searcher) has permission to access. Optionally filter the results by the owner of the refs. + let results = sqlx::query!( + r#" + WITH latest_snapshots AS ( + SELECT DISTINCT ON (snapshots.for_ref) + refs.id AS ref_id, + snapshots.content->>'name' AS name + FROM snapshots + JOIN refs ON snapshots.for_ref = refs.id + ORDER BY snapshots.for_ref, snapshots.id DESC + ) + SELECT + ls.ref_id, + ls.name + FROM latest_snapshots ls + JOIN permissions p_searcher ON ls.ref_id = p_searcher.object + JOIN users owner ON owner.id = ( + SELECT p_owner.subject + FROM permissions p_owner + WHERE p_owner.object = ls.ref_id + AND p_owner.level = 'own' + LIMIT 1 + ) + WHERE p_searcher.subject = $1 -- searcher_id + AND ( + owner.username = $2 -- owner_username + OR $2 IS NULL -- include all owners if owner_username is NULL + ) + AND p_searcher.level IN ('read', 'write', 'maintain') + AND ( + ls.name ILIKE '%' || $3 || '%' -- case-insensitive substring search + OR $3 IS NULL -- include all if name filter is NULL + ) + LIMIT 100 -- TODO: pagination; + "#, + searcher_id, + search_params.owner_username_query, + search_params.ref_name_query + ) + .fetch_all(&ctx.state.db) + .await?; + + // Map SQL query results to Vec + let stubs = results + .into_iter() + .map(|row| RefStub { + ref_id: row.ref_id, + name: row.name.unwrap_or_else(|| "untitled".to_string()), + }) + .collect(); + + Ok(stubs) +} + +/// Gets a vec of user relevant informations about refs that are related to a user and match the search parameters +pub async fn get_ref_stubs_related_to_user( + ctx: AppCtx, + search_params: RefQueryParams, +) -> Result, AppError> { + let searcher_id = ctx.user.as_ref().map(|user| user.user_id.clone()); + + // for all refs that a user has permissions for, get those that the + // searcher also has access to and filter those by search params. If no + // owner is specified, assume that the owner is the same as the searcher. + let results = sqlx::query!( + r#" + WITH latest_snapshots AS ( + SELECT DISTINCT ON (snapshots.for_ref) + refs.id AS ref_id, + snapshots.content->>'name' AS name + FROM snapshots + JOIN refs ON snapshots.for_ref = refs.id + ORDER BY snapshots.for_ref, snapshots.id DESC + ) + SELECT + ls.ref_id, + ls.name + FROM latest_snapshots ls + JOIN permissions p_searcher ON ls.ref_id = p_searcher.object + JOIN permissions p_owner ON ls.ref_id = p_owner.object -- Ensure owner has permissions + JOIN users owner ON owner.id = p_owner.subject -- Match permissions entry to owner + WHERE p_searcher.subject = $1 -- searcher_id must have access + AND ( + owner.username = COALESCE($2, (SELECT username FROM users WHERE id = $1)) -- Use searcher if owner_username is NULL + ) + AND p_owner.level IN ('read', 'write', 'maintain', 'own') -- Owner must have at least one permission + AND p_searcher.level IN ('read', 'write', 'maintain') -- Searcher must have permissions + AND ( + ls.name ILIKE '%' || $3 || '%' -- Case-insensitive substring search + OR $3 IS NULL -- Include all if name filter is NULL + ) + LIMIT 100 -- TODO: pagination; + "#, + searcher_id, + search_params.owner_username_query, + search_params.ref_name_query + ) + .fetch_all(&ctx.state.db) + .await?; + + // Map SQL query results to Vec + let stubs = results + .into_iter() + .map(|row| RefStub { + ref_id: row.ref_id, + name: row.name.unwrap_or_else(|| "untitled".to_string()), + }) + .collect(); + + Ok(stubs) +} diff --git a/packages/backend/src/rpc.rs b/packages/backend/src/rpc.rs index 185196af..bdcbce74 100644 --- a/packages/backend/src/rpc.rs +++ b/packages/backend/src/rpc.rs @@ -25,6 +25,8 @@ pub fn router() -> Router { .handler(username_status) .handler(get_active_user_profile) .handler(set_active_user_profile) + .handler(get_ref_stubs) + .handler(get_ref_stubs_related_to_user) } #[handler(mutation)] @@ -74,6 +76,22 @@ enum RefDoc { }, } +#[handler(query)] +async fn get_ref_stubs( + ctx: AppCtx, + query_params: doc::RefQueryParams, +) -> RpcResult> { + doc::get_ref_stubs(ctx, query_params).await.into() +} + +#[handler(query)] +async fn get_ref_stubs_related_to_user( + ctx: AppCtx, + query_params: doc::RefQueryParams, +) -> RpcResult> { + doc::get_ref_stubs_related_to_user(ctx, query_params).await.into() +} + #[handler(query)] async fn head_snapshot(ctx: AppCtx, ref_id: Uuid) -> RpcResult { _head_snapshot(ctx, ref_id).await.into() diff --git a/packages/backend/src/user.rs b/packages/backend/src/user.rs index eadeb923..0d5181d7 100644 --- a/packages/backend/src/user.rs +++ b/packages/backend/src/user.rs @@ -127,6 +127,16 @@ pub struct UserProfile { // pub url: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +pub struct UserProfile2 { + pub username: Option, + #[serde(rename = "displayName")] + pub display_name: Option, + // TODO: More fields, such as: + // pub bio: Option, + // pub url: Option, +} + impl UserProfile { fn validate(&self) -> Result<(), String> { if let Some(username) = self.username.as_ref() {