diff --git a/src/apps/v1/auth/auth_service.rs b/src/apps/v1/auth/auth_service.rs index 2315bc7..68ea57d 100644 --- a/src/apps/v1/auth/auth_service.rs +++ b/src/apps/v1/auth/auth_service.rs @@ -6,15 +6,13 @@ use super::{ use crate::{ common_response, decode_refresh_token, encode_access_token, encode_refresh_token, encode_reset_password_token, extract_email_token, generate_otp, get_iso_date, - hash_password, send_email, success_response, validate_request, verify_password, - AppState, Env, ResourceEnum, ResponseSuccessDto, UsersActiveInactiveSchema, - UsersItemDto, UsersRepository, UsersSchema, UsersSetNewPasswordSchema, + hash_password, make_thing, send_email, success_response, validate_request, + verify_password, AppState, Env, ResourceEnum, ResponseSuccessDto, RolesEnum, + RolesRepository, UsersActiveInactiveSchema, UsersItemDto, UsersRepository, + UsersSchema, UsersSetNewPasswordSchema, }; use axum::{http::StatusCode, response::Response}; -use surrealdb::{ - sql::{Id, Thing}, - Uuid, -}; +use surrealdb::Uuid; pub struct AuthService; @@ -114,6 +112,12 @@ impl AuthService { let user_repo = UsersRepository::new(state); let auth_repo = AuthRepository::new(state); + let role_repo = RolesRepository::new(state); + + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); if user_repo .query_user_by_email(payload.email.clone()) @@ -163,15 +167,11 @@ impl AuthService { } } - let role_thing = Thing::from(( - ResourceEnum::Roles.to_string(), - Id::String(Uuid::new_v4().to_string()), - )); - - let user_thing = Thing::from(( - ResourceEnum::Users.to_string(), - Id::String(Uuid::new_v4().to_string()), - )); + let role_thing = make_thing(&ResourceEnum::Roles.to_string(), &role.id); + let user_thing = make_thing( + &ResourceEnum::Users.to_string(), + &Uuid::new_v4().to_string(), + ); match user_repo .query_create_user(UsersSchema { diff --git a/src/apps/v1/mod.rs b/src/apps/v1/mod.rs index ed70c65..ebd020d 100644 --- a/src/apps/v1/mod.rs +++ b/src/apps/v1/mod.rs @@ -1,18 +1,20 @@ use axum::{middleware::from_fn, Router}; pub mod auth; pub mod docs; +pub mod permissions; +pub mod roles; pub mod users; pub use auth::*; pub use docs::*; +pub use permissions::*; +pub use roles::*; pub use users::*; pub async fn routes() -> Router { let public_routes = Router::new().nest("/auth", auth_router()); - let protected_routes = Router::new() .nest("/users", users_router()) .layer(from_fn(auth::auth_middleware::auth_middleware)); - Router::new().merge(public_routes).merge(protected_routes) } diff --git a/src/apps/v1/permissions/.gitkeep b/src/apps/v1/permissions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/apps/v1/permissions/mod.rs b/src/apps/v1/permissions/mod.rs new file mode 100644 index 0000000..85b6ac2 --- /dev/null +++ b/src/apps/v1/permissions/mod.rs @@ -0,0 +1,12 @@ +pub mod permissions_dto; +pub mod permissions_enum; +pub mod permissions_repository; +pub mod permissions_schema; + +#[cfg(test)] +pub mod permissions_repository_test; + +pub use permissions_dto::*; +pub use permissions_enum::*; +pub use permissions_repository::*; +pub use permissions_schema::*; diff --git a/src/apps/v1/permissions/permissions_dto.rs b/src/apps/v1/permissions/permissions_dto.rs new file mode 100644 index 0000000..9d4b9c6 --- /dev/null +++ b/src/apps/v1/permissions/permissions_dto.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct PermissionsRequestDto { + pub name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct PermissionsItemDto { + pub id: String, + pub name: String, + pub created_at: Option, + pub updated_at: Option, +} diff --git a/src/apps/v1/permissions/permissions_enum.rs b/src/apps/v1/permissions/permissions_enum.rs new file mode 100644 index 0000000..63a15dd --- /dev/null +++ b/src/apps/v1/permissions/permissions_enum.rs @@ -0,0 +1,63 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PermissionsEnum { + ReadListUsers, + ReadDetailUsers, + CreateUsers, + DeleteUsers, + UpdateUsers, + ReadListRoles, + ReadDetailRoles, + CreateRoles, + DeleteRoles, + UpdateRoles, + ReadListPermissions, + ReadDetailPermissions, + CreatePermissions, + DeletePermissions, + UpdatePermissions, + ReadListSessions, + ReadDetailSessions, + CreateSessions, + UpdateSessions, + DeleteSessions, + ReadListTests, + ReadDetailTests, + CreateTests, + UpdateTests, + DeleteTests, +} + +impl fmt::Display for PermissionsEnum { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let permission_str = match self { + PermissionsEnum::ReadListUsers => "Read List Users", + PermissionsEnum::ReadDetailUsers => "Read Detail Users", + PermissionsEnum::CreateUsers => "Create Users", + PermissionsEnum::DeleteUsers => "Delete Users", + PermissionsEnum::UpdateUsers => "Update Users", + PermissionsEnum::ReadListRoles => "Read List Roles", + PermissionsEnum::ReadDetailRoles => "Read Detail Roles", + PermissionsEnum::CreateRoles => "Create Roles", + PermissionsEnum::DeleteRoles => "Delete Roles", + PermissionsEnum::UpdateRoles => "Update Roles", + PermissionsEnum::ReadListPermissions => "Read List Permissions", + PermissionsEnum::ReadDetailPermissions => "Read Detail Permissions", + PermissionsEnum::CreatePermissions => "Create Permissions", + PermissionsEnum::DeletePermissions => "Delete Permissions", + PermissionsEnum::UpdatePermissions => "Update Permissions", + PermissionsEnum::ReadListSessions => "Read List Sessions", + PermissionsEnum::ReadDetailSessions => "Read Detail Sessions", + PermissionsEnum::CreateSessions => "Create Sessions", + PermissionsEnum::UpdateSessions => "Update Sessions", + PermissionsEnum::DeleteSessions => "Delete Sessions", + PermissionsEnum::ReadListTests => "Read List Tests", + PermissionsEnum::ReadDetailTests => "Read Detail Tests", + PermissionsEnum::CreateTests => "Create Tests", + PermissionsEnum::UpdateTests => "Update Tests", + PermissionsEnum::DeleteTests => "Delete Tests", + }; + write!(f, "{}", permission_str) + } +} diff --git a/src/apps/v1/permissions/permissions_repository.rs b/src/apps/v1/permissions/permissions_repository.rs new file mode 100644 index 0000000..c307e1f --- /dev/null +++ b/src/apps/v1/permissions/permissions_repository.rs @@ -0,0 +1,108 @@ +use super::PermissionsSchema; +use crate::{ + get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, ResourceEnum, + ResponseListSuccessDto, +}; +use anyhow::{bail, Result}; + +pub struct PermissionsRepository<'a> { + state: &'a AppState, +} + +impl<'a> PermissionsRepository<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub async fn query_permission_list( + &self, + meta: MetaRequestDto, + ) -> Result>> { + let mut conditions = vec!["is_deleted = false".into()]; + if meta.search.is_some() { + conditions.push("string::contains(name, $search)".into()); + } + if meta.filter_by.is_some() && meta.filter.is_some() { + let filter_by = meta.filter_by.as_ref().unwrap(); + conditions.push(format!("{} = $filter", filter_by)); + } + query_list_with_meta( + &self.state.surrealdb_ws, + &ResourceEnum::Permissions.to_string(), + &meta, + conditions, + ) + .await + } + + pub async fn query_permission_by_id( + &self, + id: String, + ) -> Result { + let db = &self.state.surrealdb_ws; + let result: Option = db + .select((ResourceEnum::Permissions.to_string(), id.clone())) + .await?; + match result { + Some(permission) if !permission.is_deleted => Ok(permission), + _ => bail!("Permission not found"), + } + } + + pub async fn query_create_permission( + &self, + data: PermissionsSchema, + ) -> Result { + let db = &self.state.surrealdb_ws; + let record: Option = db + .create(ResourceEnum::Permissions.to_string()) + .content(data) + .await?; + match record { + Some(_) => Ok("Success create permission".into()), + None => bail!("Failed to create permission"), + } + } + + pub async fn query_update_permission( + &self, + data: PermissionsSchema, + ) -> Result { + let db = &self.state.surrealdb_ws; + let record_key = get_id(&data.id)?; + let existing = self.query_permission_by_id(data.id.id.to_raw()).await?; + if existing.is_deleted { + bail!("Permission already deleted"); + } + let merged = PermissionsSchema { + created_at: existing.created_at, + ..data.clone() + }; + let record: Option = + db.update(record_key).merge(merged).await?; + match record { + Some(_) => Ok("Success update permission".into()), + None => bail!("Failed to update permission"), + } + } + + pub async fn query_delete_permission(&self, id: String) -> Result { + let db = &self.state.surrealdb_ws; + let permission_id = make_thing(&ResourceEnum::Permissions.to_string(), &id); + let permission = self + .query_permission_by_id(permission_id.id.to_raw()) + .await?; + if permission.is_deleted { + bail!("Permission already deleted"); + } + let record_key = get_id(&permission.id)?; + let record: Option = db + .update(record_key) + .merge(serde_json::json!({ "is_deleted": true })) + .await?; + match record { + Some(_) => Ok("Success delete permission".into()), + None => bail!("Failed to delete permission"), + } + } +} diff --git a/src/apps/v1/permissions/permissions_repository_test.rs b/src/apps/v1/permissions/permissions_repository_test.rs new file mode 100644 index 0000000..71d84fb --- /dev/null +++ b/src/apps/v1/permissions/permissions_repository_test.rs @@ -0,0 +1,111 @@ +use crate::{ + apps::v1::permissions::{PermissionsRepository, PermissionsSchema}, + create_mock_app_state, +}; +use chrono::Utc; + +fn create_dummy_permission(name: &str) -> PermissionsSchema { + PermissionsSchema { + name: name.to_string(), + ..Default::default() + } +} + +#[tokio::test] +async fn test_create_permission_should_succeed() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let permission = create_dummy_permission("Test Permission"); + let result = repo.query_create_permission(permission).await; + assert!(result.is_ok(), "Create failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_query_permission_list_should_return_data() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + + let _ = repo + .query_create_permission(create_dummy_permission("View")) + .await; + let meta = crate::MetaRequestDto { + page: Some(1), + per_page: Some(10), + search: None, + sort_by: None, + order: None, + filter: None, + filter_by: None, + }; + + let result = repo.query_permission_list(meta).await; + assert!(result.is_ok(), "List failed: {:?}", result.err()); + assert!(!result.unwrap().data.is_empty(), "Data should not be empty"); +} + +#[tokio::test] +async fn test_query_permission_by_id_should_succeed() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let permission = create_dummy_permission("Detail"); + let _ = repo.query_create_permission(permission.clone()).await; + let id = permission.id.id.to_raw(); + let result = repo.query_permission_by_id(id).await; + assert!(result.is_ok(), "Get by id failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_update_permission_should_succeed() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let mut permission = create_dummy_permission("Update This"); + let _ = repo.query_create_permission(permission.clone()).await; + permission.name = "Updated Name".into(); + permission.updated_at = Some(Utc::now().to_rfc3339()); + let result = repo.query_update_permission(permission).await; + assert!(result.is_ok(), "Update failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_delete_permission_should_succeed() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let permission = create_dummy_permission("To Be Deleted"); + let _ = repo.query_create_permission(permission.clone()).await; + let id = permission.id.id.to_raw(); + let result = repo.query_delete_permission(id).await; + assert!(result.is_ok(), "Delete failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_delete_permission_should_fail_if_already_deleted() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let permission = create_dummy_permission("Delete Twice"); + let _ = repo.query_create_permission(permission.clone()).await; + let id = permission.id.id.to_raw(); + let _ = repo.query_delete_permission(id.clone()).await; + let second = repo.query_delete_permission(id).await; + assert!(second.is_err(), "Should fail on second delete"); +} + +#[tokio::test] +async fn test_update_permission_should_fail_if_deleted() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let mut permission = create_dummy_permission("To Be Updated Then Deleted"); + let _ = repo.query_create_permission(permission.clone()).await; + let id = permission.id.id.to_raw(); + let _ = repo.query_delete_permission(id.clone()).await; + permission.name = "Try Update".into(); + let result = repo.query_update_permission(permission).await; + assert!(result.is_err(), "Update on deleted should fail"); +} + +#[tokio::test] +async fn test_query_permission_by_id_should_fail_if_not_found() { + let state = create_mock_app_state().await; + let repo = PermissionsRepository::new(&state); + let result = repo.query_permission_by_id("non-existent-id".into()).await; + assert!(result.is_err(), "Expected error for not found id"); +} diff --git a/src/apps/v1/permissions/permissions_schema.rs b/src/apps/v1/permissions/permissions_schema.rs new file mode 100644 index 0000000..35ee545 --- /dev/null +++ b/src/apps/v1/permissions/permissions_schema.rs @@ -0,0 +1,30 @@ +use crate::ResourceEnum; +use serde::{Deserialize, Serialize}; +use surrealdb::{ + sql::{Id, Thing}, + Uuid, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PermissionsSchema { + pub id: Thing, + pub name: String, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, +} + +impl Default for PermissionsSchema { + fn default() -> Self { + PermissionsSchema { + id: Thing::from(( + ResourceEnum::Permissions.to_string(), + Id::String(Uuid::new_v4().to_string()), + )), + name: String::new(), + is_deleted: false, + created_at: None, + updated_at: None, + } + } +} diff --git a/src/apps/v1/roles/.gitkeep b/src/apps/v1/roles/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/apps/v1/roles/mod.rs b/src/apps/v1/roles/mod.rs index e69de29..339a5db 100644 --- a/src/apps/v1/roles/mod.rs +++ b/src/apps/v1/roles/mod.rs @@ -0,0 +1,12 @@ +pub mod roles_dto; +pub mod roles_enum; +pub mod roles_repository; +pub mod roles_schema; + +#[cfg(test)] +pub mod roles_repository_test; + +pub use roles_dto::*; +pub use roles_enum::*; +pub use roles_repository::*; +pub use roles_schema::*; diff --git a/src/apps/v1/roles/roles_dto.rs b/src/apps/v1/roles/roles_dto.rs new file mode 100644 index 0000000..96cb1ea --- /dev/null +++ b/src/apps/v1/roles/roles_dto.rs @@ -0,0 +1,33 @@ +use crate::PermissionsItemDto; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct RolesRequestUpdateDto { + pub name: Option, + pub permissions: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct RolesRequestCreateDto { + pub name: String, + pub permissions: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct RolesItemListDto { + pub id: String, + pub name: String, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct RolesItemDto { + pub id: String, + pub name: String, + pub is_deleted: bool, + pub permissions: Vec, + pub created_at: Option, + pub updated_at: Option, +} diff --git a/src/apps/v1/roles/roles_enum.rs b/src/apps/v1/roles/roles_enum.rs new file mode 100644 index 0000000..bbdfe47 --- /dev/null +++ b/src/apps/v1/roles/roles_enum.rs @@ -0,0 +1,19 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RolesEnum { + Admin, + Student, + Staf, +} + +impl fmt::Display for RolesEnum { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let roles_str = match self { + RolesEnum::Admin => "Admin", + RolesEnum::Student => "Student", + RolesEnum::Staf => "Staf", + }; + write!(f, "{}", roles_str) + } +} diff --git a/src/apps/v1/roles/roles_repository.rs b/src/apps/v1/roles/roles_repository.rs new file mode 100644 index 0000000..6ef2abc --- /dev/null +++ b/src/apps/v1/roles/roles_repository.rs @@ -0,0 +1,203 @@ +use super::{ + RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, RolesSchema, +}; +use crate::{ + get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, + PermissionsItemDto, ResourceEnum, ResponseListSuccessDto, +}; +use anyhow::{bail, Result}; +use surrealdb::sql::Thing; +use surrealdb::Uuid; + +pub struct RolesRepository<'a> { + state: &'a AppState, +} + +impl<'a> RolesRepository<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub async fn query_raw_role_by_id(&self, id: &str) -> Result { + let db = &self.state.surrealdb_ws; + let role: Option = + db.select((ResourceEnum::Roles.to_string(), id)).await?; + match role { + Some(r) if !r.is_deleted => Ok(r), + _ => bail!("Role not found"), + } + } + + pub async fn query_role_list( + &self, + meta: MetaRequestDto, + ) -> Result>> { + let mut conditions = vec!["is_deleted = false".into()]; + if meta.search.is_some() { + conditions.push("string::contains(name, $search)".into()); + } + if meta.filter_by.is_some() && meta.filter.is_some() { + let filter_by = meta.filter_by.as_ref().unwrap(); + conditions.push(format!("{} = $filter", filter_by)); + } + query_list_with_meta( + &self.state.surrealdb_ws, + &ResourceEnum::Roles.to_string(), + &meta, + conditions, + ) + .await + } + + pub async fn query_role_by_name(&self, name: String) -> Result { + let db = &self.state.surrealdb_ws; + let sql = format!( + "SELECT * FROM {} WHERE name = $name AND is_deleted = false", + ResourceEnum::Roles.to_string() + ); + let mut response: Vec = + db.query(sql).bind(("name", name.clone())).await?.take(0)?; + + let role = match response.pop() { + Some(r) => r, + None => bail!("Role not found"), + }; + let mut permissions: Vec = Vec::new(); + for permission_thing in &role.permissions { + let result: Option = db + .select((permission_thing.tb.clone(), permission_thing.id.to_string())) + .await?; + if let Some(p) = result { + permissions.push(p); + } + } + Ok(RolesItemDto { + id: role.id.id.to_raw(), + name: role.name, + is_deleted: role.is_deleted, + permissions, + created_at: role.created_at, + updated_at: role.updated_at, + }) + } + + pub async fn query_role_by_id(&self, id: String) -> Result { + let db = &self.state.surrealdb_ws; + + let role: Option = db + .select((ResourceEnum::Roles.to_string(), id.clone())) + .await?; + + let role = match role { + Some(r) if !r.is_deleted => r, + _ => bail!("Role not found"), + }; + + let mut permissions: Vec = Vec::new(); + + for permission_thing in &role.permissions { + let result: Option = db + .select((permission_thing.tb.clone(), permission_thing.id.to_string())) + .await?; + if let Some(p) = result { + permissions.push(p); + } + } + + Ok(RolesItemDto { + id: role.id.id.to_raw(), + name: role.name, + is_deleted: role.is_deleted, + permissions, + created_at: role.created_at, + updated_at: role.updated_at, + }) + } + + pub async fn query_create_role( + &self, + payload: RolesRequestCreateDto, + ) -> Result { + let db = &self.state.surrealdb_ws; + + let role_id = Uuid::new_v4().to_string(); + let permission_things: Vec = payload + .permissions + .iter() + .map(|id| make_thing(&ResourceEnum::Permissions.to_string(), id)) + .collect(); + + let role = RolesSchema { + id: make_thing(&ResourceEnum::Roles.to_string(), &role_id), + name: payload.name, + is_deleted: false, + permissions: permission_things, + created_at: Some(crate::get_iso_date()), + updated_at: Some(crate::get_iso_date()), + }; + + let _: Option = db + .create((&ResourceEnum::Roles.to_string(), role_id)) + .content(role) + .await?; + + Ok("Role with permissions created successfully".into()) + } + + pub async fn query_update_role( + &self, + id: String, + data: RolesRequestUpdateDto, + ) -> Result { + let db = &self.state.surrealdb_ws; + let thing_id = make_thing(&ResourceEnum::Roles.to_string(), &id); + let existing = self.query_raw_role_by_id(&id).await?; + if existing.is_deleted { + bail!("Role already deleted"); + } + let permissions: Vec = if let Some(permission_ids) = &data.permissions { + permission_ids + .iter() + .map(|id| make_thing(&ResourceEnum::Permissions.to_string(), id)) + .collect() + } else { + existing + .permissions + .iter() + .map(|p| make_thing(&ResourceEnum::Permissions.to_string(), &p.id.to_raw())) + .collect() + }; + let merged = RolesSchema { + id: thing_id, + name: data.name.unwrap_or(existing.name), + permissions, + is_deleted: existing.is_deleted, + created_at: existing.created_at, + updated_at: Some(crate::get_iso_date()), + }; + let record: Option = + db.update(get_id(&merged.id)?).content(merged).await?; + match record { + Some(_) => Ok("Success update role".into()), + None => bail!("Failed to update role"), + } + } + + pub async fn query_delete_role(&self, id: String) -> Result { + let db = &self.state.surrealdb_ws; + let role_id = make_thing(&ResourceEnum::Roles.to_string(), &id); + let role = self.query_role_by_id(role_id.id.to_raw()).await?; + if role.is_deleted { + bail!("Role already deleted"); + } + let record_key = get_id(&role_id)?; + let record: Option = db + .update(record_key) + .merge(serde_json::json!({ "is_deleted": true })) + .await?; + match record { + Some(_) => Ok("Success delete role".into()), + None => bail!("Failed to delete role"), + } + } +} diff --git a/src/apps/v1/roles/roles_repository_test.rs b/src/apps/v1/roles/roles_repository_test.rs new file mode 100644 index 0000000..4c2e63e --- /dev/null +++ b/src/apps/v1/roles/roles_repository_test.rs @@ -0,0 +1,194 @@ +use crate::{ + apps::v1::{ + permissions::{ + permissions_repository::PermissionsRepository, + permissions_schema::PermissionsSchema, + }, + roles::{ + roles_dto::{RolesRequestCreateDto, RolesRequestUpdateDto}, + roles_repository::RolesRepository, + }, + }, + create_mock_app_state, get_iso_date, make_thing, ResourceEnum, +}; +use surrealdb::sql::Thing; +use surrealdb::Uuid; + +#[tokio::test] +async fn test_query_create_role_should_succeed() { + let state = create_mock_app_state().await; + let perm_repo = PermissionsRepository::new(&state); + let role_repo = RolesRepository::new(&state); + let perm_id = Uuid::new_v4().to_string(); + let permission = PermissionsSchema { + id: Thing::from((ResourceEnum::Permissions.to_string(), perm_id.clone())), + name: "Read Quiz".into(), + is_deleted: false, + created_at: Some(get_iso_date()), + updated_at: None, + }; + perm_repo.query_create_permission(permission).await.unwrap(); + let payload = RolesRequestCreateDto { + name: "Student".into(), + permissions: vec![perm_id.clone()], + }; + let result = role_repo.query_create_role(payload).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_query_role_by_name_should_return_data() { + let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let payload = RolesRequestCreateDto { + name: "Viewer".into(), + permissions: vec![], + }; + role_repo.query_create_role(payload.clone()).await.unwrap(); + let role = role_repo.query_role_by_name("Viewer".into()).await.unwrap(); + assert_eq!(role.name, "Viewer"); +} + +#[tokio::test] +async fn test_query_role_by_id_should_return_data() { + let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let payload = RolesRequestCreateDto { + name: "Tester".into(), + permissions: vec![], + }; + role_repo.query_create_role(payload.clone()).await.unwrap(); + let role = role_repo.query_role_by_name("Tester".into()).await.unwrap(); + let result = role_repo.query_role_by_id(role.id).await.unwrap(); + assert_eq!(result.name, "Tester"); +} + +#[tokio::test] +async fn test_query_update_role_should_update_name_and_permissions() { + let state = create_mock_app_state().await; + let repo = RolesRepository::new(&state); + let perm_repo = PermissionsRepository::new(&state); + let original_perm_id = Uuid::new_v4().to_string(); + let original_perm = PermissionsSchema { + id: make_thing(&ResourceEnum::Permissions.to_string(), &original_perm_id), + name: "Original Permission".into(), + is_deleted: false, + created_at: Some(crate::get_iso_date()), + updated_at: None, + }; + perm_repo + .query_create_permission(original_perm) + .await + .unwrap(); + let create_payload = RolesRequestCreateDto { + name: "Role For Update".into(), + permissions: vec![original_perm_id.clone()], + }; + repo.query_create_role(create_payload).await.unwrap(); + let existing_role = repo + .query_role_by_name("Role For Update".into()) + .await + .unwrap(); + let existing_role_id = existing_role.id.clone(); + let new_perm_id = Uuid::new_v4().to_string(); + let new_perm = PermissionsSchema { + id: make_thing(&ResourceEnum::Permissions.to_string(), &new_perm_id), + name: "New Permission".into(), + is_deleted: false, + created_at: Some(crate::get_iso_date()), + updated_at: None, + }; + perm_repo.query_create_permission(new_perm).await.unwrap(); + let update_payload = RolesRequestUpdateDto { + name: Some("Updated Role Name".into()), + permissions: Some(vec![new_perm_id.clone()]), + }; + let update_result = repo + .query_update_role(existing_role_id.clone(), update_payload) + .await; + assert!(update_result.is_ok()); + let updated = repo + .query_role_by_id(existing_role_id.clone()) + .await + .unwrap(); + assert_eq!(updated.name, "Updated Role Name"); +} + +#[tokio::test] +async fn test_query_delete_role_should_soft_delete() { + let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let payload = RolesRequestCreateDto { + name: "Temporary".into(), + permissions: vec![], + }; + role_repo.query_create_role(payload.clone()).await.unwrap(); + let role = role_repo + .query_role_by_name("Temporary".into()) + .await + .unwrap(); + let result = role_repo.query_delete_role(role.id.clone()).await; + assert!(result.is_ok()); + let deleted = role_repo.query_role_by_id(role.id).await; + assert!(deleted.is_err()); +} +#[tokio::test] +async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_provided( +) { + let state = create_mock_app_state().await; + let repo = RolesRepository::new(&state); + let perm_repo = PermissionsRepository::new(&state); + let perm_id = Uuid::new_v4().to_string(); + let permission = PermissionsSchema { + id: make_thing(&ResourceEnum::Permissions.to_string(), &perm_id), + name: "Permission for Fallback".into(), + is_deleted: false, + created_at: Some(crate::get_iso_date()), + updated_at: None, + }; + perm_repo.query_create_permission(permission).await.unwrap(); + let create_payload = RolesRequestCreateDto { + name: "Role With Permission".into(), + permissions: vec![perm_id.clone()], + }; + repo.query_create_role(create_payload).await.unwrap(); + let existing = repo + .query_role_by_name("Role With Permission".into()) + .await + .unwrap(); + let existing_id = existing.id.clone(); + let update_payload = RolesRequestUpdateDto { + name: Some("Updated Role Name".into()), + permissions: None, + }; + let update_res = repo + .query_update_role(existing_id.clone(), update_payload) + .await; + assert!(update_res.is_ok()); +} + +#[tokio::test] +async fn test_query_role_by_name_should_fail_if_not_found() { + let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let result = role_repo.query_role_by_name("ghost-role".into()).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_query_delete_role_should_fail_if_already_deleted() { + let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let payload = RolesRequestCreateDto { + name: "SoftDeleteTest".into(), + permissions: vec![], + }; + role_repo.query_create_role(payload.clone()).await.unwrap(); + let role = role_repo + .query_role_by_name("SoftDeleteTest".into()) + .await + .unwrap(); + role_repo.query_delete_role(role.id.clone()).await.unwrap(); + let result = role_repo.query_delete_role(role.id); + assert!(result.await.is_err()); +} diff --git a/src/apps/v1/roles/roles_schema.rs b/src/apps/v1/roles/roles_schema.rs new file mode 100644 index 0000000..dbfa978 --- /dev/null +++ b/src/apps/v1/roles/roles_schema.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use surrealdb::{ + sql::{Id, Thing}, + Uuid, +}; + +use crate::ResourceEnum; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RolesSchema { + pub id: Thing, + pub name: String, + pub is_deleted: bool, + pub permissions: Vec, + pub created_at: Option, + pub updated_at: Option, +} + +impl Default for RolesSchema { + fn default() -> Self { + RolesSchema { + id: Thing::from(( + ResourceEnum::Roles.to_string(), + Id::String(Uuid::new_v4().to_string()), + )), + permissions: vec![Thing::from(( + ResourceEnum::Permissions.to_string(), + Id::String(Uuid::new_v4().to_string()), + ))], + name: String::new(), + is_deleted: false, + created_at: None, + updated_at: None, + } + } +} diff --git a/src/apps/v1/users/users_controller_test.rs b/src/apps/v1/users/users_controller_test.rs index d3b11cb..9380892 100644 --- a/src/apps/v1/users/users_controller_test.rs +++ b/src/apps/v1/users/users_controller_test.rs @@ -1,6 +1,7 @@ use crate::{ - apps::v1::users_router, create_mock_app_state, UsersActiveInactiveRequestDto, - UsersCreateRequestDto, UsersRepository, UsersUpdateRequestDto, + apps::v1::users_router, create_mock_app_state, RolesEnum, RolesRepository, + UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersRepository, + UsersUpdateRequestDto, }; use axum::{http::StatusCode, Extension}; use axum_test::TestServer; @@ -20,6 +21,12 @@ async fn test_get_user_list_should_return_200() { #[tokio::test] async fn test_create_user_should_return_201() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id; let app = axum::Router::new() .nest("/v1/users", users_router()) .layer(Extension(state)); @@ -27,6 +34,7 @@ async fn test_create_user_should_return_201() { let payload = UsersCreateRequestDto { fullname: "Create Data #1".into(), email: "test@create.com".into(), + role_id, password: "Password1!".into(), student_type: "general".into(), phone_number: "081234567890".into(), @@ -41,6 +49,12 @@ async fn test_create_user_should_return_201() { #[tokio::test] async fn test_delete_user_should_return_200() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id; let repo = UsersRepository::new(&state); let app = axum::Router::new() .nest("/v1/users", users_router()) @@ -51,6 +65,7 @@ async fn test_delete_user_should_return_200() { email: "test@create.com".into(), password: "Password1!".into(), student_type: "general".into(), + role_id, phone_number: "081234567890".into(), is_active: true, referral_code: None, @@ -69,6 +84,12 @@ async fn test_delete_user_should_return_200() { #[tokio::test] async fn test_get_user_detail_should_return_200() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id; let repo = UsersRepository::new(&state); let app = axum::Router::new() .nest("/v1/users", users_router()) @@ -77,6 +98,7 @@ async fn test_get_user_detail_should_return_200() { let payload = UsersCreateRequestDto { fullname: "Detail User".into(), email: "detail@test.com".into(), + role_id, password: "Password1!".into(), student_type: "general".into(), phone_number: "081234567890".into(), @@ -94,6 +116,12 @@ async fn test_get_user_detail_should_return_200() { #[tokio::test] async fn test_activate_user_should_return_200() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id; let repo = UsersRepository::new(&state); let app = axum::Router::new() .nest("/v1/users", users_router()) @@ -103,6 +131,7 @@ async fn test_activate_user_should_return_200() { fullname: "Inactive User".into(), email: "inactive@test.com".into(), password: "Password1!".into(), + role_id, student_type: "general".into(), phone_number: "081234567890".into(), is_active: false, @@ -122,6 +151,12 @@ async fn test_activate_user_should_return_200() { #[tokio::test] async fn test_update_user_should_return_200() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id.clone(); let repo = UsersRepository::new(&state); let app = axum::Router::new() .nest("/v1/users", users_router()) @@ -131,6 +166,7 @@ async fn test_update_user_should_return_200() { fullname: "Old Name".into(), email: "update@test.com".into(), password: "Password1!".into(), + role_id: role_id.clone(), student_type: "general".into(), phone_number: "081234567890".into(), is_active: true, @@ -145,6 +181,7 @@ async fn test_update_user_should_return_200() { email: "update@test.com".into(), student_type: "general".into(), phone_number: "081234567890".into(), + role_id: role_id.clone(), is_active: true, referral_code: None, referred_by: None, @@ -165,6 +202,12 @@ async fn test_update_user_should_return_200() { #[tokio::test] async fn test_create_user_should_fail_if_email_taken() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id.clone(); let app = axum::Router::new() .nest("/v1/users", users_router()) .layer(Extension(state.clone())); @@ -174,6 +217,7 @@ async fn test_create_user_should_fail_if_email_taken() { email: "taken@example.com".into(), password: "Password1!".into(), student_type: "general".into(), + role_id, phone_number: "081234567890".into(), is_active: true, referral_code: None, @@ -203,6 +247,12 @@ async fn test_get_user_detail_should_fail_if_not_found() { #[tokio::test] async fn test_delete_user_should_fail_if_already_deleted() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id.clone(); let repo = UsersRepository::new(&state); let app = axum::Router::new() .nest("/v1/users", users_router()) @@ -211,6 +261,7 @@ async fn test_delete_user_should_fail_if_already_deleted() { let payload = UsersCreateRequestDto { fullname: "To Be Deleted".into(), email: "delete-me@example.com".into(), + role_id, password: "Password1!".into(), student_type: "general".into(), phone_number: "081234567890".into(), @@ -232,6 +283,12 @@ async fn test_delete_user_should_fail_if_already_deleted() { #[tokio::test] async fn test_update_user_should_fail_if_user_not_found() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id.clone(); let app = axum::Router::new() .nest("/v1/users", users_router()) .layer(Extension(state)); @@ -241,6 +298,7 @@ async fn test_update_user_should_fail_if_user_not_found() { email: "nonexistent@test.com".into(), student_type: "general".into(), phone_number: "081234567890".into(), + role_id, is_active: true, referral_code: None, referred_by: None, @@ -330,21 +388,6 @@ async fn test_list_users_should_fallback_on_invalid_order() { assert_eq!(res.status_code(), StatusCode::OK); } -#[tokio::test] -async fn test_list_users_with_search_should_return_empty_if_no_match() { - let state = create_mock_app_state().await; - let app = axum::Router::new() - .nest("/v1/users", users_router()) - .layer(Extension(state)); - let server = TestServer::new(app).unwrap(); - let res = server - .get("/v1/users?page=1&per_page=10&search=no-user-should-match-this") - .await; - assert_eq!(res.status_code(), StatusCode::OK); - let body: serde_json::Value = res.json(); - assert_eq!(body["data"].as_array().unwrap().len(), 0); -} - #[tokio::test] async fn test_list_users_with_invalid_filter_by_should_return_empty() { let state = create_mock_app_state().await; @@ -389,12 +432,19 @@ async fn test_user_detail_should_fail_if_user_not_found() { #[tokio::test] async fn test_user_detail_should_fail_if_user_is_soft_deleted() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id.clone(); let repo = UsersRepository::new(&state); let app = axum::Router::new() .nest("/v1/users", users_router()) .layer(Extension(state.clone())); let server = TestServer::new(app).unwrap(); let payload = UsersCreateRequestDto { + role_id, fullname: "Soft Deleted".into(), email: "softdeleted@test.com".into(), password: "Password1!".into(), @@ -417,12 +467,19 @@ async fn test_user_detail_should_fail_if_user_is_soft_deleted() { #[tokio::test] async fn test_update_user_should_fail_if_user_is_deleted() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id.clone(); let repo = UsersRepository::new(&state); let app = axum::Router::new() .nest("/v1/users", users_router()) .layer(Extension(state.clone())); let server = TestServer::new(app).unwrap(); let payload = UsersCreateRequestDto { + role_id: role_id.clone(), fullname: "To Be Deleted".into(), email: "deleteduser@test.com".into(), password: "Password1!".into(), @@ -437,6 +494,7 @@ async fn test_update_user_should_fail_if_user_is_deleted() { let user_id = user.id.id.to_raw(); let _ = repo.query_delete_user(user_id.clone()).await.unwrap(); let update_payload = UsersUpdateRequestDto { + role_id: role_id.clone(), fullname: "Should Fail".into(), email: "deleteduser@test.com".into(), student_type: "general".into(), @@ -462,6 +520,12 @@ async fn test_update_user_should_fail_if_user_is_deleted() { #[tokio::test] async fn test_update_user_should_fail_if_payload_invalid() { let state = create_mock_app_state().await; + let role_repo = RolesRepository::new(&state); + let role = role_repo + .query_role_by_name(RolesEnum::Student.to_string()) + .await + .unwrap(); + let role_id = role.id.clone(); let repo = UsersRepository::new(&state); let app = axum::Router::new() .nest("/v1/users", users_router()) @@ -470,6 +534,7 @@ async fn test_update_user_should_fail_if_payload_invalid() { let payload = UsersCreateRequestDto { fullname: "Invalid Payload".into(), email: "invalid@test.com".into(), + role_id: role_id.clone(), password: "Password1!".into(), student_type: "general".into(), phone_number: "081234567890".into(), @@ -487,6 +552,7 @@ async fn test_update_user_should_fail_if_payload_invalid() { phone_number: "081234567890".into(), is_active: true, referral_code: None, + role_id: role_id.clone(), referred_by: None, identity_number: Some("1234567890123456".into()), religion: Some("Islam".into()), diff --git a/src/apps/v1/users/users_dto.rs b/src/apps/v1/users/users_dto.rs index 02ae30a..099cab8 100644 --- a/src/apps/v1/users/users_dto.rs +++ b/src/apps/v1/users/users_dto.rs @@ -40,6 +40,7 @@ pub struct UsersCreateRequestDto { pub referral_code: Option, pub referred_by: Option, pub is_active: bool, + pub role_id: String, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] @@ -75,6 +76,7 @@ pub struct UsersUpdateRequestDto { pub gender: Option, pub birthdate: Option, pub avatar: Option, + pub role_id: String, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] diff --git a/src/apps/v1/users/users_repository.rs b/src/apps/v1/users/users_repository.rs index 555ef83..7633d63 100644 --- a/src/apps/v1/users/users_repository.rs +++ b/src/apps/v1/users/users_repository.rs @@ -1,7 +1,7 @@ use super::{UsersActiveInactiveSchema, UsersSchema, UsersSetNewPasswordSchema}; use crate::{ - get_id, make_thing, AppState, CountResult, MetaRequestDto, MetaResponseDto, - ResourceEnum, ResponseListSuccessDto, + get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, ResourceEnum, + ResponseListSuccessDto, }; use anyhow::{bail, Result}; @@ -20,102 +20,15 @@ impl<'a> UsersRepository<'a> { ) -> Result>> { let db = &self.state.surrealdb_ws; let table = ResourceEnum::Users.to_string(); - - let page = meta.page.unwrap_or(1); - let per_page = meta.per_page.unwrap_or(10); - - if page < 1 || per_page < 1 { - bail!("Invalid pagination: page and per_page must be greater than 0"); - } - - let start = (page - 1) * per_page; - - let mut sql = format!("SELECT * FROM {}", table); let mut conditions = vec!["is_deleted = false".to_string()]; - if meta.search.is_some() { - conditions.push("string::contains(fullname, $search)".to_string()); + conditions.push("string::contains(name, $search)".into()); } - if meta.filter_by.is_some() && meta.filter.is_some() { let filter_by = meta.filter_by.as_ref().unwrap(); conditions.push(format!("{} = $filter", filter_by)); } - - if !conditions.is_empty() { - sql.push_str(" WHERE "); - sql.push_str(&conditions.join(" AND ")); - } - - if let Some(sort_by) = &meta.sort_by { - let order = match meta - .order - .clone() - .unwrap_or_else(|| "ASC".to_string()) - .to_uppercase() - .as_str() - { - "ASC" => "ASC", - "DESC" => "DESC", - _ => "ASC", // fallback kalau order invalid - }; - sql.push_str(&format!(" ORDER BY {} {}", sort_by, order)); - } - - sql.push_str(" LIMIT $per_page START $start"); - - let mut query = db.query(sql); - - if let Some(search) = meta.search.clone() { - query = query.bind(("search", search)); - } - - if let Some(filter_val) = meta.filter.clone() { - if let Ok(b) = filter_val.parse::() { - query = query.bind(("filter", b)); - } else if let Ok(i) = filter_val.parse::() { - query = query.bind(("filter", i)); - } else { - query = query.bind(("filter", filter_val)); - } - } - - query = query.bind(("per_page", per_page)).bind(("start", start)); - - let users: Vec = query.await?.take(0)?; - - let mut count_sql = - format!("SELECT count() FROM {} WHERE is_deleted = false", table); - if meta.filter_by.is_some() && meta.filter.is_some() { - let filter_by = meta.filter_by.as_ref().unwrap(); - count_sql.push_str(&format!(" AND {} = $filter", filter_by)); - } - - let mut count_query = db.query(count_sql); - - if let Some(filter_val) = meta.filter.clone() { - if let Ok(b) = filter_val.parse::() { - count_query = count_query.bind(("filter", b)); - } else if let Ok(i) = filter_val.parse::() { - count_query = count_query.bind(("filter", i)); - } else { - count_query = count_query.bind(("filter", filter_val)); - } - } - - let count_result: Vec = count_query.await?.take(0)?; - let total = count_result.first().map(|c| c.count); - - let meta_res = MetaResponseDto { - page: Some(page), - per_page: Some(per_page), - total, - }; - - Ok(ResponseListSuccessDto { - data: users, - meta: Some(meta_res), - }) + query_list_with_meta::(db, &table, &meta, conditions).await } pub async fn query_user_by_email(&self, email: String) -> Result { diff --git a/src/apps/v1/users/users_repository_test.rs b/src/apps/v1/users/users_repository_test.rs index 6321d52..ba4dd31 100644 --- a/src/apps/v1/users/users_repository_test.rs +++ b/src/apps/v1/users/users_repository_test.rs @@ -1,4 +1,4 @@ -use crate::create_mock_app_state; +use crate::{create_mock_app_state, ResourceEnum}; use crate::{ make_thing, MetaRequestDto, UsersActiveInactiveSchema, UsersRepository, UsersSchema, UsersSetNewPasswordSchema, @@ -23,7 +23,7 @@ fn create_test_user(email: &str, fullname: &str, is_active: bool) -> UsersSchema gender: None, birthdate: None, is_profile_completed: false, - role: make_thing("roles", "user"), + role: make_thing(&ResourceEnum::Roles.to_string(), "user"), created_at: None, updated_at: None, } diff --git a/src/apps/v1/users/users_service.rs b/src/apps/v1/users/users_service.rs index d17e3fb..c533975 100644 --- a/src/apps/v1/users/users_service.rs +++ b/src/apps/v1/users/users_service.rs @@ -53,7 +53,6 @@ impl UsersService { return common_response(status, &message); } let repo = UsersRepository::new(state); - if repo .query_user_by_email(new_user.email.clone()) .await @@ -61,6 +60,7 @@ impl UsersService { { return common_response(StatusCode::BAD_REQUEST, "User already exists"); } + let role_thing = make_thing(&ResourceEnum::Roles.to_string(), &new_user.role_id); match repo .query_create_user(UsersSchema { email: new_user.email.clone(), @@ -71,6 +71,7 @@ impl UsersService { referred_by: new_user.referred_by.clone(), student_type: new_user.student_type.clone(), is_active: new_user.is_active.clone(), + role: role_thing, is_profile_completed: false, ..Default::default() }) diff --git a/src/entities/common_dto.rs b/src/entities/common_dto.rs index d730330..407131b 100644 --- a/src/entities/common_dto.rs +++ b/src/entities/common_dto.rs @@ -22,6 +22,20 @@ pub struct MetaRequestDto { pub filter_by: Option, } +impl Default for MetaRequestDto { + fn default() -> Self { + MetaRequestDto { + page: Some(1), + per_page: Some(10), + search: None, + sort_by: None, + order: None, + filter: None, + filter_by: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] pub struct MetaResponseDto { pub page: Option, diff --git a/src/utils/bind_filter.rs b/src/utils/bind_filter.rs index 8b13789..c922765 100644 --- a/src/utils/bind_filter.rs +++ b/src/utils/bind_filter.rs @@ -1 +1,14 @@ +use surrealdb::{engine::remote::ws::Client, method::Query}; +pub fn bind_filter_value( + query: Query<'_, Client>, + val: String, +) -> Query<'_, Client> { + if let Ok(b) = val.parse::() { + query.bind(("filter", b)) + } else if let Ok(i) = val.parse::() { + query.bind(("filter", i)) + } else { + query.bind(("filter", val)) + } +} diff --git a/src/utils/mock_test.rs b/src/utils/mock_test.rs index 8339dfe..2373d4f 100644 --- a/src/utils/mock_test.rs +++ b/src/utils/mock_test.rs @@ -1,4 +1,5 @@ -use crate::{hash_password, AppState, UsersSchema}; +use crate::{hash_password, AppState, ResourceEnum, UsersSchema}; +use rand::Rng; use surrealdb::{ engine::{local::Mem, remote::ws::Ws}, opt::auth::Root, @@ -60,7 +61,7 @@ pub fn create_test_user( UsersSchema { id: make_thing("app_users", &Uuid::new_v4().to_string()), email: email.to_string(), - fullname: fullname.to_string(), + fullname: format!("Randomize {} {}", fullname, rand::rng().random::()), password: hash_password("secret").unwrap(), is_deleted: false, avatar: None, @@ -74,7 +75,7 @@ pub fn create_test_user( gender: None, birthdate: None, is_profile_completed: false, - role: make_thing("roles", "user"), + role: make_thing(&ResourceEnum::Roles.to_string(), "user"), created_at: None, updated_at: None, } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d006483..0504a49 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -5,14 +5,17 @@ pub mod generate_otp; pub mod get_id; pub mod make_thing; pub mod mock_test; +pub mod query_list; pub mod response_format; pub mod validator; +pub use bind_filter::*; pub use extract_email::*; pub use generate_date::*; pub use generate_otp::*; pub use get_id::*; pub use make_thing::*; pub use mock_test::*; +pub use query_list::*; pub use response_format::*; pub use validator::*; diff --git a/src/utils/query_list.rs b/src/utils/query_list.rs new file mode 100644 index 0000000..56cb139 --- /dev/null +++ b/src/utils/query_list.rs @@ -0,0 +1,74 @@ +use crate::{CountResult, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto}; +use anyhow::{bail, Result}; +use serde::{de::DeserializeOwned, Serialize}; +use surrealdb::{engine::remote::ws::Client, Surreal}; + +use super::bind_filter_value; + +pub async fn query_list_with_meta( + db: &Surreal, + table: &str, + meta: &MetaRequestDto, + conditions: Vec, +) -> Result>> +where + T: DeserializeOwned + Serialize, +{ + let page = meta.page.unwrap_or(1); + let per_page = meta.per_page.unwrap_or(10); + if page < 1 || per_page < 1 { + bail!("Invalid pagination: page and per_page must be greater than 0"); + } + let start = (page - 1) * per_page; + let mut sql = format!("SELECT * FROM {}", table); + if !conditions.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&conditions.join(" AND ")); + } + if let Some(sort_by) = &meta.sort_by { + let order = match meta + .order + .clone() + .unwrap_or_else(|| "ASC".into()) + .to_uppercase() + .as_str() + { + "ASC" => "ASC", + "DESC" => "DESC", + _ => "ASC", + }; + sql.push_str(&format!(" ORDER BY {} {}", sort_by, order)); + } + sql.push_str(" LIMIT $per_page START $start"); + let mut query = db.query(sql); + if let Some(search) = &meta.search { + if !search.is_empty() { + query = query.bind(("search", search.clone())); + } + } + if let Some(filter_val) = meta.filter.clone() { + query = bind_filter_value(query, filter_val); + } + query = query.bind(("per_page", per_page)).bind(("start", start)); + let items: Vec = query.await?.take(0)?; + let mut count_sql = format!("SELECT count() FROM {}", table); + if !conditions.is_empty() { + count_sql.push_str(" WHERE "); + count_sql.push_str(&conditions.join(" AND ")); + } + let mut count_query = db.query(count_sql); + if let Some(filter_val) = meta.filter.clone() { + count_query = bind_filter_value(count_query, filter_val); + } + let count_result: Vec = count_query.await?.take(0)?; + let total = count_result.first().map(|c| c.count); + let meta = MetaResponseDto { + page: Some(page), + per_page: Some(per_page), + total, + }; + Ok(ResponseListSuccessDto { + data: items, + meta: Some(meta), + }) +}