diff --git a/.sqlx/query-a4610ec5cb6fdccf7ef07a0526d35b7659a7e953b47b1e7b87188fa4b7277b1f.json b/.sqlx/query-a4610ec5cb6fdccf7ef07a0526d35b7659a7e953b47b1e7b87188fa4b7277b1f.json index a096c51a..58660edd 100644 --- a/.sqlx/query-a4610ec5cb6fdccf7ef07a0526d35b7659a7e953b47b1e7b87188fa4b7277b1f.json +++ b/.sqlx/query-a4610ec5cb6fdccf7ef07a0526d35b7659a7e953b47b1e7b87188fa4b7277b1f.json @@ -30,26 +30,31 @@ }, { "ordinal": 5, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 6, "name": "encryption_sign", "type_info": "Text" }, { - "ordinal": 6, + "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "latest_workspace_id", "type_info": "Uuid" } @@ -69,6 +74,7 @@ true, true, true, + true, true ] }, diff --git a/libs/database-entity/src/lib.rs b/libs/database-entity/src/lib.rs index 11a79c9c..05cfb08e 100644 --- a/libs/database-entity/src/lib.rs +++ b/libs/database-entity/src/lib.rs @@ -160,6 +160,7 @@ pub struct AFUserProfileView { pub email: Option, pub password: Option, pub name: Option, + pub metadata: Option, pub encryption_sign: Option, pub deleted_at: Option>, pub updated_at: Option>, diff --git a/libs/database/src/user.rs b/libs/database/src/user.rs index afb8fad9..91d8b445 100644 --- a/libs/database/src/user.rs +++ b/libs/database/src/user.rs @@ -1,16 +1,32 @@ use anyhow::Context; use database_entity::database_error::DatabaseError; use sqlx::postgres::PgArguments; +use sqlx::types::JsonValue; use sqlx::{Arguments, Executor, PgPool, Postgres}; use tracing::{instrument, warn}; use uuid::Uuid; +/// Updates the user's details in the `af_user` table. +/// +/// This function allows for updating the user's name, email, and metadata based on the provided UUID. +/// If the `metadata` is provided, it merges the new metadata with the existing one, with the new values +/// overriding the old ones in case of conflicts. +/// +/// # Arguments +/// +/// * `pool` - A reference to the database connection pool. +/// * `user_uuid` - The UUID of the user to be updated. +/// * `name` - An optional new name for the user. +/// * `email` - An optional new email for the user. +/// * `metadata` - An optional JSON value containing new metadata for the user. +/// #[instrument(skip_all, err)] pub async fn update_user( pool: &PgPool, user_uuid: &uuid::Uuid, name: Option, email: Option, + metadata: Option, ) -> Result<(), DatabaseError> { let mut set_clauses = Vec::new(); let mut args = PgArguments::default(); @@ -28,6 +44,13 @@ pub async fn update_user( args.add(e); } + if let Some(m) = metadata { + args_num += 1; + // Merge existing metadata with new metadata + set_clauses.push(format!("metadata = metadata || ${}", args_num)); + args.add(m); + } + if set_clauses.is_empty() { warn!("No update params provided"); return Ok(()); diff --git a/libs/shared-entity/Cargo.toml b/libs/shared-entity/Cargo.toml index 14e9e54b..09b87245 100644 --- a/libs/shared-entity/Cargo.toml +++ b/libs/shared-entity/Cargo.toml @@ -17,7 +17,7 @@ gotrue-entity = { path = "../gotrue-entity" } database-entity = { path = "../database-entity" } actix-web = { version = "4.4.0", default-features = false, features = ["http2"], optional = true } -sqlx = { version = "0.7", default-features = false, features = ["postgres"], optional = true } +sqlx = { version = "0.7", default-features = false, features = ["postgres", "json"], optional = true } validator = { version = "0.16", features = ["validator_derive", "derive"], optional = true } opener = "0.6.1" url = "2.4.1" diff --git a/libs/shared-entity/src/dto/auth_dto.rs b/libs/shared-entity/src/dto/auth_dto.rs index 00266a96..8866c9dc 100644 --- a/libs/shared-entity/src/dto/auth_dto.rs +++ b/libs/shared-entity/src/dto/auth_dto.rs @@ -1,39 +1,58 @@ // Data Transfer Objects (DTO) use gotrue_entity::AccessTokenResponse; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; -#[derive(serde::Deserialize, serde::Serialize)] +#[derive(Deserialize, Serialize)] pub struct SignInParams { pub email: String, pub password: String, } +#[derive(Default, Deserialize, Serialize, Clone)] +pub struct UserMetaData(HashMap); +impl UserMetaData { + pub fn new() -> Self { + Self::default() + } + pub fn into_inner(self) -> HashMap { + self.0 + } + + pub fn insert>(&mut self, key: &str, value: T) { + self.0.insert(key.to_string(), value.into()); + } +} + #[derive(serde::Deserialize, serde::Serialize, Default)] pub struct UpdateUsernameParams { pub name: Option, pub password: Option, pub email: Option, + pub metadata: Option, } impl UpdateUsernameParams { pub fn new() -> Self { Self::default() } - pub fn with_password(mut self, password: T) -> Self { self.password = Some(password.to_string()); self } - pub fn with_name(mut self, name: T) -> Self { self.name = Some(name.to_string()); self } - pub fn with_email(mut self, email: T) -> Self { self.email = Some(email.to_string()); self } + pub fn with_metadata>(mut self, metadata: T) -> Self { + self.metadata = Some(metadata.into()); + self + } } #[derive(serde::Deserialize, serde::Serialize)] diff --git a/migrations/20230312043023_user.sql b/migrations/20230312043023_user.sql index 082e961f..7737df9e 100644 --- a/migrations/20230312043023_user.sql +++ b/migrations/20230312043023_user.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS af_user ( email TEXT NOT NULL DEFAULT '' UNIQUE, -- not needed when authenticated with gotrue password TEXT NOT NULL DEFAULT '', -- not needed when authenticated with gotrue name TEXT NOT NULL DEFAULT '', + metadata JSONB DEFAULT '{}'::JSONB, -- used to user's metadata such as avatar, OpenAI key, etc. encryption_sign TEXT DEFAULT NULL, -- used to encrypt the user's data deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, diff --git a/src/api/user.rs b/src/api/user.rs index 781b4c00..609f0aea 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -23,11 +23,11 @@ use tracing_actix_web::RequestId; pub fn user_scope() -> Scope { web::scope("/api/user") // auth server integration - .service(web::resource("/verify/{access_token}").route(web::get().to(verify_handler))) - .service(web::resource("/update").route(web::post().to(update_handler))) - .service(web::resource("/profile").route(web::get().to(profile_handler))) + .service(web::resource("/verify/{access_token}").route(web::get().to(verify_user_handler))) + .service(web::resource("/update").route(web::post().to(update_user_handler))) + .service(web::resource("/profile").route(web::get().to(get_user_profile_handler))) - // native + // deprecated .service(web::resource("/login").route(web::post().to(login_handler))) .service(web::resource("/logout").route(web::get().to(logout_handler))) .service(web::resource("/register").route(web::post().to(register_handler))) @@ -35,7 +35,7 @@ pub fn user_scope() -> Scope { } #[tracing::instrument(skip(state, path), err)] -async fn verify_handler( +async fn verify_user_handler( path: web::Path, state: Data, required_id: RequestId, @@ -47,7 +47,7 @@ async fn verify_handler( } #[tracing::instrument(skip(state), err)] -async fn profile_handler( +async fn get_user_profile_handler( uuid: UserUuid, state: Data, required_id: RequestId, @@ -57,7 +57,7 @@ async fn profile_handler( } #[tracing::instrument(skip(state, auth, payload), err)] -async fn update_handler( +async fn update_user_handler( auth: Authorization, payload: Json, state: Data, diff --git a/src/biz/user.rs b/src/biz/user.rs index febcd11f..d1084661 100644 --- a/src/biz/user.rs +++ b/src/biz/user.rs @@ -2,6 +2,7 @@ use anyhow::Result; use database::{user::create_user_if_not_exists, workspace::select_user_profile_view_by_uuid}; use database_entity::AFUserProfileView; use gotrue::api::Client; +use serde_json::json; use shared_entity::app_error::AppError; use uuid::Uuid; @@ -35,7 +36,8 @@ pub async fn update_user( user_uuid: Uuid, params: UpdateUsernameParams, ) -> Result<(), AppError> { - Ok(database::user::update_user(pg_pool, &user_uuid, params.name, params.email).await?) + let metadata = params.metadata.map(|m| json!(m.into_inner())); + Ok(database::user::update_user(pg_pool, &user_uuid, params.name, params.email, metadata).await?) } // Best effort to get user's name after oauth diff --git a/tests/user/update.rs b/tests/user/update.rs index 590e8d76..eade5917 100644 --- a/tests/user/update.rs +++ b/tests/user/update.rs @@ -1,4 +1,5 @@ -use shared_entity::dto::auth_dto::UpdateUsernameParams; +use serde_json::json; +use shared_entity::dto::auth_dto::{UpdateUsernameParams, UserMetaData}; use shared_entity::error_code::ErrorCode; use crate::localhost_client; @@ -73,3 +74,69 @@ async fn update_user_name() { let profile = c.get_profile().await.unwrap(); assert_eq!(profile.name.unwrap().as_str(), "lucas"); } + +#[tokio::test] +async fn update_user_metadata() { + let (c, user) = generate_unique_registered_user_client().await; + c.sign_in_password(&user.email, &user.password) + .await + .unwrap(); + + let mut metadata = UserMetaData::new(); + metadata.insert("str_value", "value"); + metadata.insert("int_value", 1); + + c.update_user(UpdateUsernameParams::new().with_metadata(metadata.clone())) + .await + .unwrap(); + + let profile = c.get_profile().await.unwrap(); + assert_eq!(profile.metadata.unwrap(), json!(metadata)); +} + +#[tokio::test] +async fn user_metadata_override() { + let (c, user) = generate_unique_registered_user_client().await; + c.sign_in_password(&user.email, &user.password) + .await + .unwrap(); + + let mut metadata_1 = UserMetaData::new(); + metadata_1.insert("str_value", "value"); + metadata_1.insert("int_value", 1); + c.update_user(UpdateUsernameParams::new().with_metadata(metadata_1.clone())) + .await + .unwrap(); + + let mut metadata_2 = UserMetaData::new(); + metadata_2.insert("bool_value", false); + c.update_user(UpdateUsernameParams::new().with_metadata(metadata_2)) + .await + .unwrap(); + metadata_1.insert("bool_value", false); + + let profile = c.get_profile().await.unwrap(); + assert_eq!(profile.metadata.unwrap(), json!(metadata_1)); +} + +#[tokio::test] +async fn user_empty_metadata_override() { + let (c, user) = generate_unique_registered_user_client().await; + c.sign_in_password(&user.email, &user.password) + .await + .unwrap(); + + let mut metadata_1 = UserMetaData::new(); + metadata_1.insert("str_value", "value"); + metadata_1.insert("int_value", 1); + c.update_user(UpdateUsernameParams::new().with_metadata(metadata_1.clone())) + .await + .unwrap(); + + c.update_user(UpdateUsernameParams::new().with_metadata(UserMetaData::new())) + .await + .unwrap(); + + let profile = c.get_profile().await.unwrap(); + assert_eq!(profile.metadata.unwrap(), json!(metadata_1)); +}