diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 2a5b957..90cfa60 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -25,31 +25,14 @@ jobs: - name: Install grcov run: cargo install grcov - - name: Run tests with coverage instrumentation - working-directory: backend - run: | - CARGO_INCREMENTAL=0 \ - RUSTFLAGS="-Cinstrument-coverage" \ - LLVM_PROFILE_FILE="coverage-%p-%m.profraw" \ - cargo test --all --verbose - - - name: Generate coverage report (lcov) - working-directory: backend - run: | - grcov . \ - --binary-path ./target/debug/ \ - -s . -t lcov \ - --branch --ignore-not-existing \ - -o coverage.lcov - - name: SonarCloud Scan uses: SonarSource/sonarcloud-github-action@v2 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: + with: args: > - -Dsonar.projectBaseDir=backend - -Dsonar.projectKey=zzaekkii_challenge-to-do - -Dsonar.organization=zzaekkii + -Dsonar.projectBaseDir=backend + -Dsonar.projectKey=backend + -Dsonar.organization=morutine -Dsonar.javascript.lcov.reportPaths=coverage.lcov diff --git a/backend/todo-api/src/common/error/database_error_wrapper.rs b/backend/todo-api/src/common/error/database_error_wrapper.rs new file mode 100644 index 0000000..6e2a828 --- /dev/null +++ b/backend/todo-api/src/common/error/database_error_wrapper.rs @@ -0,0 +1,4 @@ +use infra::database::database_error_code::DatabaseErrorCode; + +#[derive(Debug)] +pub struct DatabaseApiError(pub DatabaseErrorCode); diff --git a/backend/todo-api/src/common/error/error_into_response.rs b/backend/todo-api/src/common/error/error_into_response.rs index f66915b..70d3d94 100644 --- a/backend/todo-api/src/common/error/error_into_response.rs +++ b/backend/todo-api/src/common/error/error_into_response.rs @@ -6,9 +6,9 @@ use axum::{ use serde_json::json; use crate::common::error::app_error::AppError; -use crate::common::error::postgres_error_wrapper::PostgresApiError; +use crate::common::error::database_error_wrapper::DatabaseApiError; use common::error::{CommonErrorCode, ErrorCode}; -use infra::database::postgres::PostgresError; +use infra::database::database_error_code::DatabaseErrorCode; // AppError -> HTTP Response impl IntoResponse for AppError { @@ -26,12 +26,12 @@ impl IntoResponse for AppError { } // PostgresError -> HTTP Response -impl IntoResponse for PostgresApiError { +impl IntoResponse for DatabaseApiError { fn into_response(self) -> Response { let app_error: AppError = match self.0 { - PostgresError::UniqueViolation => AppError::new(CommonErrorCode::Conflict), + DatabaseErrorCode::UniqueViolation(_) => AppError::new(CommonErrorCode::Conflict), - PostgresError::NotFound => AppError::new(CommonErrorCode::NotFound), + DatabaseErrorCode::NotFound => AppError::new(CommonErrorCode::NotFound), _ => AppError::new(CommonErrorCode::InternalServerError), }; diff --git a/backend/todo-api/src/common/error/mod.rs b/backend/todo-api/src/common/error/mod.rs index e9001d4..025731c 100644 --- a/backend/todo-api/src/common/error/mod.rs +++ b/backend/todo-api/src/common/error/mod.rs @@ -1,3 +1,3 @@ pub mod app_error; +pub mod database_error_wrapper; pub mod error_into_response; -mod postgres_error_wrapper; diff --git a/backend/todo-api/src/common/error/postgres_error_wrapper.rs b/backend/todo-api/src/common/error/postgres_error_wrapper.rs deleted file mode 100644 index 071b964..0000000 --- a/backend/todo-api/src/common/error/postgres_error_wrapper.rs +++ /dev/null @@ -1,4 +0,0 @@ -use infra::database::postgres::PostgresError; - -#[derive(Debug)] -pub struct PostgresApiError(pub PostgresError); diff --git a/backend/todo-api/src/domain/mod.rs b/backend/todo-api/src/domain/mod.rs index ac77f63..6b3bf2f 100644 --- a/backend/todo-api/src/domain/mod.rs +++ b/backend/todo-api/src/domain/mod.rs @@ -1 +1,3 @@ pub mod system; +pub mod todo; +pub mod user; diff --git a/backend/todo-api/src/domain/todo/handlers.rs b/backend/todo-api/src/domain/todo/handlers.rs new file mode 100644 index 0000000..3491d2d --- /dev/null +++ b/backend/todo-api/src/domain/todo/handlers.rs @@ -0,0 +1,20 @@ +// use axum::extract::{Query, State}; +// use axum::response::IntoResponse; +// use serde::Deserialize; +// use domain::todo::todo_error_code::TodoErrorCode; +// use crate::bootstrap::AppState; +// use crate::common::error::app_error::AppError; +// use crate::common::response::api_response::ApiResponse; +// +// #[derive(Deserialize)] +// pub struct MonthlyQuery { +// pub year: i32, +// pub month: u32, +// } +// +// pub async fn get_monthly_todos( +// State(state): State(AppState), +// Query(q): Query, +// ) -> Result, AppError> { +// +// } diff --git a/backend/todo-api/src/domain/todo/mod.rs b/backend/todo-api/src/domain/todo/mod.rs new file mode 100644 index 0000000..c0c696a --- /dev/null +++ b/backend/todo-api/src/domain/todo/mod.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod routes; diff --git a/backend/todo-api/src/domain/todo/routes.rs b/backend/todo-api/src/domain/todo/routes.rs new file mode 100644 index 0000000..10be5bf --- /dev/null +++ b/backend/todo-api/src/domain/todo/routes.rs @@ -0,0 +1,9 @@ +// use axum::Router; +// use axum::routing::{get, post, patch, delete}; +// +// pub fn todo_routes() -> Router { +// Router::new() +// .route("/", get(get_monthly_todos).post(create_todo)) +// .route("/:id", patch(update_todo).delete(delete_todo)) +// .route("/:id/status", patch(update_todo_status)) +// } diff --git a/backend/todo-api/src/domain/user/handlers.rs b/backend/todo-api/src/domain/user/handlers.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/todo-api/src/domain/user/handlers.rs @@ -0,0 +1 @@ + diff --git a/backend/todo-api/src/domain/user/mod.rs b/backend/todo-api/src/domain/user/mod.rs new file mode 100644 index 0000000..c0c696a --- /dev/null +++ b/backend/todo-api/src/domain/user/mod.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod routes; diff --git a/backend/todo-api/src/domain/user/routes.rs b/backend/todo-api/src/domain/user/routes.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/todo-api/src/domain/user/routes.rs @@ -0,0 +1 @@ + diff --git a/backend/todo-api/src/routes.rs b/backend/todo-api/src/routes.rs index df0fed8..7bf6106 100644 --- a/backend/todo-api/src/routes.rs +++ b/backend/todo-api/src/routes.rs @@ -6,9 +6,12 @@ use crate::domain::system::routes::system_routes; use axum::Router; pub fn create_app_router(config: &AppConfig) -> Router { - Router::new() - .nest("/system", system_routes()) - .layer(build_compression_layer()) - .layer(build_concurrency_limit_layer()) - .layer(build_cors(config)) + Router::new().nest( + "/api/v1", + Router::new() + .nest("/system", system_routes()) + .layer(build_compression_layer()) + .layer(build_concurrency_limit_layer()) + .layer(build_cors(config)), + ) } diff --git a/backend/todo-domain/Cargo.toml b/backend/todo-domain/Cargo.toml index 9092dfe..ca4d40f 100644 --- a/backend/todo-domain/Cargo.toml +++ b/backend/todo-domain/Cargo.toml @@ -4,4 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] +derive_builder = "0.20" +chrono = { version = "0.4", features = ["clock"] } + common = { path = "../todo-common" } +anyhow = "1.0.100" +async-trait = "0.1.89" +thiserror = "2.0.17" diff --git a/backend/todo-domain/src/lib.rs b/backend/todo-domain/src/lib.rs index b93cf3f..88599c8 100644 --- a/backend/todo-domain/src/lib.rs +++ b/backend/todo-domain/src/lib.rs @@ -1,14 +1,2 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub mod todo; +pub mod user; diff --git a/backend/todo-domain/src/todo/mod.rs b/backend/todo-domain/src/todo/mod.rs new file mode 100644 index 0000000..74ec38c --- /dev/null +++ b/backend/todo-domain/src/todo/mod.rs @@ -0,0 +1,5 @@ +pub mod models; +pub mod repository; +pub mod todo_error_code; + +pub use todo_error_code::TodoErrorCode::*; diff --git a/backend/todo-domain/src/todo/models/mod.rs b/backend/todo-domain/src/todo/models/mod.rs new file mode 100644 index 0000000..123c696 --- /dev/null +++ b/backend/todo-domain/src/todo/models/mod.rs @@ -0,0 +1,3 @@ +pub mod todo; +pub mod todo_item; +pub mod todo_item_status; diff --git a/backend/todo-domain/src/todo/models/todo.rs b/backend/todo-domain/src/todo/models/todo.rs new file mode 100644 index 0000000..88e768f --- /dev/null +++ b/backend/todo-domain/src/todo/models/todo.rs @@ -0,0 +1,352 @@ +use crate::todo::models::todo_item::TodoItem; +use crate::todo::todo_error_code::TodoErrorCode; +use crate::todo::{ + EmptyContent, ItemNotFound, MaxItemLimit, PastDateNotAllowed, StateChangeNotAllowed, +}; +use chrono::{DateTime, NaiveDate, Utc}; +use derive_builder::Builder; + +#[derive(Debug, Clone, Builder)] +pub struct Todo { + id: Option, + user_id: i64, + date: NaiveDate, + items: Vec, + created_at: DateTime, + modified_at: DateTime, +} + +impl Todo { + pub fn new(user_id: i64, date: NaiveDate) -> Result { + let today = Utc::now().date_naive(); + + if date < today { + return Err(PastDateNotAllowed); + } + + Ok(Todo { + id: None, + user_id, + date, + items: vec![], + created_at: Utc::now(), + modified_at: Utc::now(), + }) + } + + pub fn add_item(&mut self, content: &str) -> Result<(), TodoErrorCode> { + self.validate_creatable()?; + + if self.items.len() >= 3 { + return Err(MaxItemLimit); + } + + let item = TodoItem::new(self.user_id, content)?; + self.items.push(item); + self.modified_at = Utc::now(); + Ok(()) + } + + pub fn update_item_content( + &mut self, + item_id: i64, + new_content: &str, + ) -> Result<(), TodoErrorCode> { + self.validate_editable()?; + + if new_content.trim().is_empty() { + return Err(EmptyContent); + } + + let item = self.find_mut_item(item_id)?; + item.update_content(new_content); + + self.modified_at = Utc::now(); + Ok(()) + } + + pub fn complete_item(&mut self, item_id: i64) -> Result<(), TodoErrorCode> { + self.validate_state_modifiable()?; + let item = self.find_mut_item(item_id)?; + item.completed(); + self.modified_at = Utc::now(); + Ok(()) + } + + pub fn alter_item(&mut self, item_id: i64, content: &str) -> Result<(), TodoErrorCode> { + self.validate_state_modifiable()?; + let item = self.find_mut_item(item_id)?; + item.altered(content); + self.modified_at = Utc::now(); + Ok(()) + } + + pub fn fail_item(&mut self, item_id: i64) -> Result<(), TodoErrorCode> { + self.validate_state_modifiable()?; + let item = self.find_mut_item(item_id)?; + item.failed(); + self.modified_at = Utc::now(); + Ok(()) + } + + /// 오늘이나 미래의 할 일만 추가할 수 있다. + fn validate_creatable(&self) -> Result<(), TodoErrorCode> { + let today = Utc::now().date_naive(); + + if self.date < today { + Err(PastDateNotAllowed) + } else { + Ok(()) + } + } + + /// 당일이 되기 전에는 얼마든지 수정/삭제 가능하다. + fn validate_editable(&self) -> Result<(), TodoErrorCode> { + let today = Utc::now().date_naive(); + if self.date <= today { + Err(PastDateNotAllowed) + } else { + Ok(()) + } + } + + /// 할 일의 상태 변경은 당일부터 가능하다. + fn validate_state_modifiable(&self) -> Result<(), TodoErrorCode> { + let today = Utc::now().date_naive(); + if self.date > today { + Err(StateChangeNotAllowed) + } else { + Ok(()) + } + } + + fn find_mut_item(&mut self, item_id: i64) -> Result<&mut TodoItem, TodoErrorCode> { + self.items + .iter_mut() + .find(|item| item.id() == Some(item_id)) + .ok_or(ItemNotFound) + } + + pub fn id(&self) -> Option { + self.id + } + pub fn user_id(&self) -> i64 { + self.user_id + } + pub fn date(&self) -> NaiveDate { + self.date + } + pub fn items(&self) -> &Vec { + &self.items + } + pub fn created_at(&self) -> DateTime { + self.created_at + } + pub fn modified_at(&self) -> DateTime { + self.modified_at + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::todo::models::todo_item::TodoItemBuilder; + use crate::todo::models::todo_item_status::TodoItemStatus; + use TodoErrorCode::MaxItemLimit; + use TodoItemStatus::{Altered, Completed, Failed}; + + fn past_date() -> NaiveDate { + NaiveDate::from_ymd_opt(2000, 1, 1).unwrap() + } + + fn future_date() -> NaiveDate { + NaiveDate::from_ymd_opt(2100, 1, 1).unwrap() + } + + #[test] + fn new_todo_success() { + let date = future_date(); + let todo = Todo::new(818, date).unwrap(); + + assert_eq!(todo.user_id(), 818); + assert_eq!(todo.date(), date); + assert!(todo.items().is_empty()); + assert!(todo.id().is_none()); + } + + #[test] + fn new_todo_should_fail_for_past_date() { + let result = Todo::new(1, past_date()); + assert!(matches!(result, Err(PastDateNotAllowed))); + } + + #[test] + fn add_item_success() { + let date = future_date(); + let mut todo = Todo::new(1, date).unwrap(); + + todo.add_item("데드 100kg 10회 10세트").unwrap(); + let past_time = todo.modified_at(); + + assert_eq!(todo.items().len(), 1); + assert!(todo.modified_at() >= past_time); + } + + #[test] + fn add_item_should_fail_on_past_todo() { + let mut todo = Todo { + id: None, + user_id: 1, + date: past_date(), + items: vec![], + created_at: Utc::now(), + modified_at: Utc::now(), + }; + + let result = todo.add_item("벤치 80kg 10회 10세트"); + assert!(matches!(result, Err(PastDateNotAllowed))); + } + + #[test] + fn add_item_should_fail_when_exceeds_limit() { + let date = future_date(); + let mut todo = Todo::new(1, date).unwrap(); + + todo.add_item("벤치 80kg 10회 10세트").unwrap(); + todo.add_item("스쿼트 100kg 10회 10세트").unwrap(); + todo.add_item("데드 100kg 10회 10세트").unwrap(); + + let result = todo.add_item("OHP 40kg 10회 10세트"); + + assert!(matches!(result, Err(MaxItemLimit))); + } + + #[test] + fn complete_item_success() { + let date = Utc::now().date_naive(); + let mut todo = Todo::new(1, date).unwrap(); + + // 원래 add_item 메서드 써서 넣어야 되는데, 테스트용으로 직접 아이템을 추가 + let item = TodoItemBuilder::default() + .id(Some(10)) + .todo_id(todo.user_id()) + .content("벤치".into()) + .status(TodoItemStatus::Pending) + .created_at(Utc::now()) + .modified_at(Utc::now()) + .build() + .unwrap(); + + todo.items.push(item); + + todo.complete_item(10).unwrap(); + + assert_eq!(todo.items[0].status(), Completed); + } + + #[test] + fn complete_item_should_fail_for_not_found() { + let date = Utc::now().date_naive(); + let mut todo = Todo::new(1, date).unwrap(); + + let result = todo.complete_item(999); + + assert!(matches!(result, Err(ItemNotFound))); + } + + #[test] + fn alter_item_success() { + let date = Utc::now().date_naive(); + let mut todo = Todo::new(1, date).unwrap(); + + let item = TodoItemBuilder::default() + .id(Some(5)) + .todo_id(todo.user_id()) + .content("벤치".into()) + .status(TodoItemStatus::Pending) + .created_at(Utc::now()) + .modified_at(Utc::now()) + .build() + .unwrap(); + + todo.items.push(item); + + todo.alter_item(5, "사람 많아서 스쿼트로 변경").unwrap(); + + assert_eq!(todo.items[0].status(), Altered); + assert_eq!( + todo.items[0].altered_plan().unwrap().as_str(), + "사람 많아서 스쿼트로 변경" + ); + } + + #[test] + fn fail_item_success() { + let date = Utc::now().date_naive(); + let mut todo = Todo::new(1, date).unwrap(); + + let item = TodoItemBuilder::default() + .id(Some(3)) + .todo_id(todo.user_id()) + .content("벤치".into()) + .status(TodoItemStatus::Pending) + .created_at(Utc::now()) + .modified_at(Utc::now()) + .build() + .unwrap(); + + todo.items.push(item); + + todo.fail_item(3).unwrap(); + + assert_eq!(todo.items[0].status(), Failed); + } + + #[test] + fn update_item_content_should_succeed_until_that_date() { + let mut todo = Todo::new(1, future_date()).unwrap(); + + let item = TodoItemBuilder::default() + .id(Some(10)) + .todo_id(1) + .content("벤치".into()) + .status(TodoItemStatus::Pending) + .created_at(Utc::now()) + .modified_at(Utc::now()) + .build() + .unwrap(); + + todo.items.push(item); + + let res = todo.update_item_content(10, "스쿼트"); + assert!(res.is_ok()); + assert_eq!(todo.items[0].content(), "스쿼트"); + } + + #[test] + fn update_item_content_should_fail_for_today_or_past_todo() { + let mut todo = Todo { + id: None, + user_id: 1, + date: past_date(), + items: vec![], + created_at: Utc::now(), + modified_at: Utc::now(), + }; + + let item = TodoItemBuilder::default() + .id(Some(5)) + .todo_id(1) + .content("벤치 80kg 10회 10세트".into()) + .status(TodoItemStatus::Pending) + .created_at(Utc::now()) + .modified_at(Utc::now()) + .build() + .unwrap(); + + todo.items.push(item); + + let res = todo.update_item_content(5, "데드 100kg 10회 10세트"); + assert!(matches!(res, Err(PastDateNotAllowed))); + } +} diff --git a/backend/todo-domain/src/todo/models/todo_item.rs b/backend/todo-domain/src/todo/models/todo_item.rs new file mode 100644 index 0000000..98fc2e9 --- /dev/null +++ b/backend/todo-domain/src/todo/models/todo_item.rs @@ -0,0 +1,149 @@ +use crate::todo::models::todo_item_status::TodoItemStatus; +use crate::todo::todo_error_code::TodoErrorCode::{self, EmptyContent}; +use TodoItemStatus::{Altered, Completed, Failed, Pending}; +use chrono::{DateTime, Utc}; +use derive_builder::Builder; + +#[derive(Debug, Clone, Builder)] +pub struct TodoItem { + id: Option, + todo_id: i64, + content: String, + status: TodoItemStatus, + #[builder(default)] + altered_content: Option, + #[builder(default)] + image_url: Option, + created_at: DateTime, + modified_at: DateTime, +} + +impl TodoItem { + pub(crate) fn new(todo_id: i64, content: &str) -> Result { + if content.trim().is_empty() { + return Err(EmptyContent); + } + + Ok(Self { + id: None, + todo_id, + content: content.to_string(), + status: Pending, + altered_content: None, + image_url: None, + created_at: Utc::now(), + modified_at: Utc::now(), + }) + } + + pub(crate) fn update_content(&mut self, new_content: &str) { + self.content = new_content.to_string(); + self.modified_at = Utc::now(); + } + + pub(crate) fn completed(&mut self) { + self.status = Completed; + self.modified_at = Utc::now(); + } + + pub(crate) fn altered(&mut self, content: &str) { + self.status = Altered; + self.altered_content = Some(content.to_string()); + self.modified_at = Utc::now(); + } + + pub(crate) fn failed(&mut self) { + self.status = Failed; + self.modified_at = Utc::now(); + } + + pub fn id(&self) -> Option { + self.id + } + pub fn todo_id(&self) -> i64 { + self.todo_id + } + pub fn content(&self) -> &str { + &self.content + } + pub fn status(&self) -> TodoItemStatus { + self.status + } + pub fn altered_plan(&self) -> Option<&String> { + self.altered_content.as_ref() + } + pub fn image_url(&self) -> Option<&String> { + self.image_url.as_ref() + } + pub fn created_at(&self) -> DateTime { + self.created_at + } + pub fn modified_at(&self) -> DateTime { + self.modified_at + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_todo_item_success() { + let item = TodoItem::new(1, "벤치 프레스 80kg 10회 10세트").unwrap(); + + assert_eq!(item.todo_id(), 1); + assert_eq!(item.content(), "벤치 프레스 80kg 10회 10세트"); + assert_eq!(item.status(), Pending); + assert!(item.id().is_none()); + } + + #[test] + fn new_todo_item_empty_content_should_fail() { + let res = TodoItem::new(1, " "); + assert!(matches!(res, Err(EmptyContent))); + } + + #[test] + fn update_content_should_change_content_and_modified_at() { + let mut item = TodoItem::new(1, "벤치 프레스 80kg 10회 10세트").unwrap(); + let past_time = item.modified_at(); + + item.update_content("딥스 10회 10세트"); + + assert_eq!(item.content(), "딥스 10회 10세트"); + assert!(item.modified_at() > past_time); + } + + #[test] + fn complete_should_update_status_and_modified_at() { + let mut item = TodoItem::new(1, "todo 프로젝트 Rust Axum 백엔드 서버 구축").unwrap(); + let past_time = item.modified_at(); + + item.completed(); + + assert_eq!(item.status(), Completed); + assert!(item.modified_at() > past_time); + } + + #[test] + fn altered_should_update_status_and_plan() { + let mut item = TodoItem::new(1, "벤치 프레스 80kg 10회 10세트").unwrap(); + + item.altered("오늘 벤치 사람 너무 많아서, 스쿼트 100kg 10회 10세트로 대체"); + + assert_eq!(item.status(), Altered); + assert_eq!( + item.altered_plan(), + Some(&"오늘 벤치 사람 너무 많아서, 스쿼트 100kg 10회 10세트로 대체".to_string()) + ); + } + + #[test] + fn failed_should_update_status() { + let mut item = TodoItem::new(1, "todo 프로젝트 Svelte 프론트 제작").unwrap(); + + item.failed(); + + assert_eq!(item.status(), Failed); + } +} diff --git a/backend/todo-domain/src/todo/models/todo_item_status.rs b/backend/todo-domain/src/todo/models/todo_item_status.rs new file mode 100644 index 0000000..c4f0779 --- /dev/null +++ b/backend/todo-domain/src/todo/models/todo_item_status.rs @@ -0,0 +1,34 @@ +use crate::todo::todo_error_code::TodoErrorCode; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TodoItemStatus { + Pending, // 아직 수행 전 + Completed, // 정상 완료 + Altered, // 대체 업무 수행 + Failed, // 실패 +} + +impl TodoItemStatus { + pub fn as_str(&self) -> &'static str { + match self { + TodoItemStatus::Pending => "PENDING", + TodoItemStatus::Completed => "COMPLETED", + TodoItemStatus::Altered => "ALTERED", + TodoItemStatus::Failed => "FAILED", + } + } +} + +impl TryFrom<&str> for TodoItemStatus { + type Error = TodoErrorCode; + + fn try_from(value: &str) -> Result { + match value { + "PENDING" => Ok(TodoItemStatus::Pending), + "COMPLETED" => Ok(TodoItemStatus::Completed), + "ALTERED" => Ok(TodoItemStatus::Altered), + "FAILED" => Ok(TodoItemStatus::Failed), + _ => Err(TodoErrorCode::InvalidStatus), + } + } +} diff --git a/backend/todo-domain/src/todo/repository/mod.rs b/backend/todo-domain/src/todo/repository/mod.rs new file mode 100644 index 0000000..f1a7327 --- /dev/null +++ b/backend/todo-domain/src/todo/repository/mod.rs @@ -0,0 +1 @@ +pub mod todo_repository; diff --git a/backend/todo-domain/src/todo/repository/todo_repository.rs b/backend/todo-domain/src/todo/repository/todo_repository.rs new file mode 100644 index 0000000..edaadd8 --- /dev/null +++ b/backend/todo-domain/src/todo/repository/todo_repository.rs @@ -0,0 +1,32 @@ +use crate::todo::models::todo::Todo; +use crate::todo::models::todo_item::TodoItem; +use async_trait::async_trait; +use chrono::NaiveDate; + +#[async_trait] +pub trait TodoRepository { + async fn find_todo_by_user_and_date( + &self, + user_id: i64, + date: NaiveDate, + ) -> Result, anyhow::Error>; + + async fn insert_todo(&self, todo: &Todo) -> Result; + + async fn update_todo(&self, todo: &Todo) -> Result; + + /// item + async fn find_item_by_id(&self, item_id: i64) -> Result, anyhow::Error>; + + async fn find_items_by_todo_id(&self, todo_id: i64) -> Result, anyhow::Error>; + + async fn insert_item( + &self, + item: &TodoItem, + parent_todo_id: i64, + ) -> Result; + + async fn update_item(&self, item: &TodoItem) -> Result; + + async fn delete_item(&self, item_id: i64) -> Result<(), anyhow::Error>; +} diff --git a/backend/todo-domain/src/todo/todo_error_code.rs b/backend/todo-domain/src/todo/todo_error_code.rs new file mode 100644 index 0000000..bdc3a6d --- /dev/null +++ b/backend/todo-domain/src/todo/todo_error_code.rs @@ -0,0 +1,56 @@ +use common::constant::status::{BAD_REQUEST, CONFLICT, FORBIDDEN, NOT_FOUND}; +use common::error::{ErrorCode, ErrorReason}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TodoErrorCode { + #[error("하루 할 일 제한(3개) 초과")] + MaxItemLimit, + #[error("내용이 비어 있을 수 없음")] + EmptyContent, + #[error("과거 날짜엔 할 일 추가/변경/삭제 불가능")] + PastDateNotAllowed, + #[error("해당 할 일을 찾을 수 없음")] + ItemNotFound, + #[error("상태를 변경할 수 없음")] + StateChangeNotAllowed, + #[error("유효하지 않은 상태값")] + InvalidStatus, +} + +impl ErrorCode for TodoErrorCode { + fn reason(&self) -> ErrorReason { + match self { + TodoErrorCode::MaxItemLimit => ErrorReason { + status: CONFLICT, + code: "TODO_409_1", + message: "하루 최대 3개까지 등록 가능합니다.", + }, + TodoErrorCode::EmptyContent => ErrorReason { + status: BAD_REQUEST, + code: "TODO_400_1", + message: "내용이 비어 있을 수 없습니다.", + }, + TodoErrorCode::PastDateNotAllowed => ErrorReason { + status: FORBIDDEN, + code: "TODO_403_1", + message: "과거 날짜에는 작업할 수 없습니다.", + }, + TodoErrorCode::ItemNotFound => ErrorReason { + status: NOT_FOUND, + code: "TODO_404_1", + message: "등록된 할 일을 찾을 수 없습니다.", + }, + TodoErrorCode::StateChangeNotAllowed => ErrorReason { + status: FORBIDDEN, + code: "TODO_403_2", + message: "해당 할 일의 상태 변경이 불가능합니다.", + }, + TodoErrorCode::InvalidStatus => ErrorReason { + status: BAD_REQUEST, + code: "TODO_400_2", + message: "유효하지 않은 상태값입니다.", + }, + } + } +} diff --git a/backend/todo-domain/src/user/mod.rs b/backend/todo-domain/src/user/mod.rs new file mode 100644 index 0000000..58a35be --- /dev/null +++ b/backend/todo-domain/src/user/mod.rs @@ -0,0 +1,9 @@ +pub mod models; +pub mod repository; +pub mod user_error_code; + +pub use models::{oauth_provider::OAuthProvider, social_account::SocialAccount, user::User}; +pub use repository::{ + social_account_repository::SocialAccountRepository, user_repository::UserRepository, +}; +pub use user_error_code::UserErrorCode::*; diff --git a/backend/todo-domain/src/user/models/mod.rs b/backend/todo-domain/src/user/models/mod.rs new file mode 100644 index 0000000..9cea013 --- /dev/null +++ b/backend/todo-domain/src/user/models/mod.rs @@ -0,0 +1,3 @@ +pub mod oauth_provider; +pub mod social_account; +pub mod user; diff --git a/backend/todo-domain/src/user/models/oauth_provider.rs b/backend/todo-domain/src/user/models/oauth_provider.rs new file mode 100644 index 0000000..7c78bba --- /dev/null +++ b/backend/todo-domain/src/user/models/oauth_provider.rs @@ -0,0 +1,27 @@ +use crate::user::user_error_code::UserErrorCode; +use UserErrorCode::InvalidProvider; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OAuthProvider { + Kakao, +} + +impl OAuthProvider { + pub fn as_str(&self) -> &'static str { + match self { + OAuthProvider::Kakao => "KAKAO", + } + } +} + +impl FromStr for OAuthProvider { + type Err = UserErrorCode; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "kakao" => Ok(OAuthProvider::Kakao), + _ => Err(InvalidProvider), + } + } +} diff --git a/backend/todo-domain/src/user/models/social_account.rs b/backend/todo-domain/src/user/models/social_account.rs new file mode 100644 index 0000000..843edfb --- /dev/null +++ b/backend/todo-domain/src/user/models/social_account.rs @@ -0,0 +1,56 @@ +use crate::user::models::oauth_provider::OAuthProvider; +use crate::user::user_error_code::UserErrorCode; +use chrono::{DateTime, Utc}; +use derive_builder::Builder; + +#[derive(Debug, Clone, Builder)] +pub struct SocialAccount { + id: Option, + user_id: i64, + provider: OAuthProvider, + provider_user_id: String, + created_at: DateTime, + modified_at: DateTime, +} + +impl SocialAccount { + pub fn new( + user_id: i64, + provider: OAuthProvider, + provider_user_id: String, + ) -> Result { + if provider_user_id.trim().is_empty() { + return Err(UserErrorCode::InvalidProviderUserId); + } + + let now = Utc::now(); + + Ok(Self { + id: None, + user_id, + provider, + provider_user_id, + created_at: now, + modified_at: now, + }) + } + + pub fn id(&self) -> Option { + self.id + } + pub fn user_id(&self) -> i64 { + self.user_id + } + pub fn provider(&self) -> &OAuthProvider { + &self.provider + } + pub fn provider_user_id(&self) -> &str { + &self.provider_user_id + } + pub fn created_at(&self) -> DateTime { + self.created_at + } + pub fn modified_at(&self) -> DateTime { + self.modified_at + } +} diff --git a/backend/todo-domain/src/user/models/user.rs b/backend/todo-domain/src/user/models/user.rs new file mode 100644 index 0000000..0d57364 --- /dev/null +++ b/backend/todo-domain/src/user/models/user.rs @@ -0,0 +1,46 @@ +use crate::user::user_error_code::UserErrorCode; +use chrono::{DateTime, Utc}; +use derive_builder::Builder; + +#[derive(Debug, Clone, Builder)] +pub struct User { + id: Option, + nickname: String, + profile_image_url: Option, + created_at: DateTime, + modified_at: DateTime, +} + +impl User { + pub fn new(nickname: String, profile_image_url: Option) -> Result { + if nickname.trim().is_empty() { + return Err(UserErrorCode::InvalidNickname); + } + + let now = Utc::now(); + + Ok(User { + id: None, + nickname, + profile_image_url, + created_at: now, + modified_at: now, + }) + } + + pub fn id(&self) -> Option { + self.id + } + pub fn nickname(&self) -> &str { + &self.nickname + } + pub fn profile_image_url(&self) -> Option<&String> { + self.profile_image_url.as_ref() + } + pub fn created_at(&self) -> DateTime { + self.created_at + } + pub fn modified_at(&self) -> DateTime { + self.modified_at + } +} diff --git a/backend/todo-domain/src/user/repository/mod.rs b/backend/todo-domain/src/user/repository/mod.rs new file mode 100644 index 0000000..e629be3 --- /dev/null +++ b/backend/todo-domain/src/user/repository/mod.rs @@ -0,0 +1,2 @@ +pub mod social_account_repository; +pub mod user_repository; diff --git a/backend/todo-domain/src/user/repository/social_account_repository.rs b/backend/todo-domain/src/user/repository/social_account_repository.rs new file mode 100644 index 0000000..cbc009e --- /dev/null +++ b/backend/todo-domain/src/user/repository/social_account_repository.rs @@ -0,0 +1,22 @@ +use crate::user::models::oauth_provider::OAuthProvider; +use crate::user::models::social_account::SocialAccount; +use async_trait::async_trait; + +#[async_trait] +pub trait SocialAccountRepository { + /// 로그인용 — 소셜 인증 → 내부 회원 찾기 + async fn find_by_provider_and_user_id( + &self, + provider: OAuthProvider, + provider_user_id: &str, + ) -> Result, anyhow::Error>; + + /// 계정 연동/조회용 + async fn find_by_user_id_and_provider( + &self, + user_id: i64, + provider: &OAuthProvider, + ) -> Result, anyhow::Error>; + + async fn insert(&self, social_account: &SocialAccount) -> Result; +} diff --git a/backend/todo-domain/src/user/repository/user_repository.rs b/backend/todo-domain/src/user/repository/user_repository.rs new file mode 100644 index 0000000..476452f --- /dev/null +++ b/backend/todo-domain/src/user/repository/user_repository.rs @@ -0,0 +1,12 @@ +use crate::user::models::user::User; +use async_trait::async_trait; + +#[async_trait] +pub trait UserRepository { + async fn find_by_id(&self, id: i64) -> Result, anyhow::Error>; + async fn find_by_nickname(&self, nickname: &str) -> Result, anyhow::Error>; + + async fn insert(&self, user: &mut User) -> Result; + async fn update(&self, user: &User) -> Result; + async fn delete(&self, id: i64) -> Result<(), anyhow::Error>; +} diff --git a/backend/todo-domain/src/user/user_error_code.rs b/backend/todo-domain/src/user/user_error_code.rs new file mode 100644 index 0000000..096112c --- /dev/null +++ b/backend/todo-domain/src/user/user_error_code.rs @@ -0,0 +1,35 @@ +use common::constant::status::BAD_REQUEST; +use common::error::{ErrorCode, ErrorReason}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum UserErrorCode { + #[error("유효하지 않은 닉네임")] + InvalidNickname, + #[error("유효하지 않은 OAuth 제공자")] + InvalidProvider, + #[error("유효하지 않은 OAuth 유저 ID")] + InvalidProviderUserId, +} + +impl ErrorCode for UserErrorCode { + fn reason(&self) -> ErrorReason { + match self { + UserErrorCode::InvalidNickname => ErrorReason { + status: BAD_REQUEST, + code: "USER_400_1", + message: "유효하지 않은 닉네임입니다.", + }, + UserErrorCode::InvalidProvider => ErrorReason { + status: BAD_REQUEST, + code: "USER_400_2", + message: "유효하지 않은 OAuth 제공자입니다.", + }, + UserErrorCode::InvalidProviderUserId => ErrorReason { + status: BAD_REQUEST, + code: "USER_400_3", + message: "유효하지 않은 OAuth 유저 ID입니다.", + }, + } + } +} diff --git a/backend/todo-infra/Cargo.toml b/backend/todo-infra/Cargo.toml index b968082..e474cd4 100644 --- a/backend/todo-infra/Cargo.toml +++ b/backend/todo-infra/Cargo.toml @@ -12,3 +12,5 @@ anyhow = "1.0" domain = { path = "../todo-domain" } common = { path = "../todo-common" } +chrono = "0.4.42" +thiserror = "2.0.17" diff --git a/backend/todo-infra/src/database/database_error_code.rs b/backend/todo-infra/src/database/database_error_code.rs new file mode 100644 index 0000000..97b1fc9 --- /dev/null +++ b/backend/todo-infra/src/database/database_error_code.rs @@ -0,0 +1,43 @@ +use common::constant::status::{CONFLICT, INTERNAL_SERVER_ERROR, NOT_FOUND}; +use common::error::{ErrorCode, ErrorReason}; +use sea_orm::DbErr; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DatabaseErrorCode { + #[error("데이터베이스에 연결할 수 없음")] + ConnectionError(#[source] DbErr), + #[error("쿼리 실행 중 오류 발생")] + QueryError(#[source] DbErr), + #[error("이미 존재하는 값")] + UniqueViolation(#[source] DbErr), + #[error("데이터를 찾을 수 없음")] + NotFound, +} + +impl ErrorCode for DatabaseErrorCode { + fn reason(&self) -> ErrorReason { + match self { + DatabaseErrorCode::ConnectionError(_) => ErrorReason { + status: INTERNAL_SERVER_ERROR, + code: "DB_500_1", + message: "데이터베이스에 연결할 수 없습니다.", + }, + DatabaseErrorCode::QueryError(_) => ErrorReason { + status: INTERNAL_SERVER_ERROR, + code: "DB_500_2", + message: "쿼리 실행 중 오류가 발생했습니다.", + }, + DatabaseErrorCode::UniqueViolation(_) => ErrorReason { + status: CONFLICT, + code: "DB_409_1", + message: "이미 존재하는 값입니다.", + }, + DatabaseErrorCode::NotFound => ErrorReason { + status: NOT_FOUND, + code: "DB_404_1", + message: "데이터를 찾을 수 없습니다.", + }, + } + } +} diff --git a/backend/todo-infra/src/database/mod.rs b/backend/todo-infra/src/database/mod.rs index 26e9103..3fda8d8 100644 --- a/backend/todo-infra/src/database/mod.rs +++ b/backend/todo-infra/src/database/mod.rs @@ -1 +1,2 @@ +pub mod database_error_code; pub mod postgres; diff --git a/backend/todo-infra/src/database/postgres/error.rs b/backend/todo-infra/src/database/postgres/error.rs deleted file mode 100644 index d7cea4e..0000000 --- a/backend/todo-infra/src/database/postgres/error.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[derive(Debug)] -pub enum PostgresError { - ConnectionError, - Timeout, - UniqueViolation, - NotFound, -} diff --git a/backend/todo-infra/src/database/postgres/mod.rs b/backend/todo-infra/src/database/postgres/mod.rs index 5c67827..7b697c5 100644 --- a/backend/todo-infra/src/database/postgres/mod.rs +++ b/backend/todo-infra/src/database/postgres/mod.rs @@ -1,3 +1,2 @@ -pub mod error; - -pub use error::PostgresError; +pub mod todo; +mod user; diff --git a/backend/todo-infra/src/database/postgres/todo/mod.rs b/backend/todo-infra/src/database/postgres/todo/mod.rs new file mode 100644 index 0000000..4b6ed98 --- /dev/null +++ b/backend/todo-infra/src/database/postgres/todo/mod.rs @@ -0,0 +1,4 @@ +pub mod todo_entity; +pub mod todo_item_entity; +pub mod todo_mapper; +pub mod todo_repository_impl; diff --git a/backend/todo-infra/src/database/postgres/todo/todo_entity.rs b/backend/todo-infra/src/database/postgres/todo/todo_entity.rs new file mode 100644 index 0000000..27b68f0 --- /dev/null +++ b/backend/todo-infra/src/database/postgres/todo/todo_entity.rs @@ -0,0 +1,33 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "todos")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub user_id: i64, + pub date: Date, + pub created_at: DateTimeWithTimeZone, + pub modified_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + TodoItems, +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Relation::TodoItems => Entity::has_many(super::todo_item_entity::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TodoItems.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/todo-infra/src/database/postgres/todo/todo_item_entity.rs b/backend/todo-infra/src/database/postgres/todo/todo_item_entity.rs new file mode 100644 index 0000000..0c7f059 --- /dev/null +++ b/backend/todo-infra/src/database/postgres/todo/todo_item_entity.rs @@ -0,0 +1,40 @@ +use super::todo_entity; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "todo_items")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub todo_id: i64, + pub content: String, + pub status: String, + pub altered_content: Option, + pub image_url: Option, + pub created_at: DateTimeWithTimeZone, + pub modified_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Todo, +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Relation::Todo => Entity::belongs_to(todo_entity::Entity) + .from(Column::TodoId) + .to(todo_entity::Column::Id) + .into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Todo.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/todo-infra/src/database/postgres/todo/todo_mapper.rs b/backend/todo-infra/src/database/postgres/todo/todo_mapper.rs new file mode 100644 index 0000000..291fbb0 --- /dev/null +++ b/backend/todo-infra/src/database/postgres/todo/todo_mapper.rs @@ -0,0 +1,87 @@ +use super::{todo_entity, todo_item_entity}; +use domain::todo::models::{todo::Todo, todo_item::TodoItem}; +use domain::todo::models::{ + todo::TodoBuilder, todo_item::TodoItemBuilder, todo_item_status::TodoItemStatus, +}; +use domain::todo::todo_error_code::TodoErrorCode; +use sea_orm::{NotSet, Set}; + +pub struct TodoMapper; + +impl TodoMapper { + pub fn map_todo_to_entity(todo: &Todo) -> todo_entity::ActiveModel { + todo_entity::ActiveModel { + id: todo.id().map(Set).unwrap_or(NotSet), + user_id: Set(todo.user_id()), + date: Set(todo.date()), + created_at: Set(todo.created_at().into()), + modified_at: Set(todo.modified_at().into()), + } + } + + pub fn map_item_to_entity(item: &TodoItem) -> todo_item_entity::ActiveModel { + todo_item_entity::ActiveModel { + id: item.id().map(Set).unwrap_or(NotSet), + todo_id: Set(item.todo_id()), + content: Set(item.content().to_string()), + status: Set(item.status().as_str().to_string()), + altered_content: Set(item.altered_plan().cloned()), + image_url: Set(item.image_url().cloned()), + created_at: Set(item.created_at().into()), + modified_at: Set(item.modified_at().into()), + } + } + + pub fn map_entity_to_todo( + todo: todo_entity::Model, + items: Vec, + ) -> Result { + let mut item_models = Vec::new(); + + for i in items { + let status = TodoItemStatus::try_from(i.status.as_str())?; + + let item = TodoItemBuilder::default() + .id(Some(i.id)) + .todo_id(i.todo_id) + .content(i.content) + .status(status) + .altered_content(i.altered_content) + .image_url(i.image_url) + .created_at(i.created_at.into()) + .modified_at(i.modified_at.into()) + .build() + .map_err(|_| TodoErrorCode::InvalidStatus)?; + + item_models.push(item); + } + + let todo_model = TodoBuilder::default() + .id(Some(todo.id)) + .user_id(todo.user_id) + .date(todo.date) + .items(item_models) + .created_at(todo.created_at.into()) + .modified_at(todo.modified_at.into()) + .build() + .map_err(|_| TodoErrorCode::InvalidStatus)?; + + Ok(todo_model) + } + + pub fn map_entity_to_item(entity: todo_item_entity::Model) -> Result { + let status = TodoItemStatus::try_from(entity.status.as_str())?; + + TodoItemBuilder::default() + .id(Some(entity.id)) + .todo_id(entity.todo_id) + .content(entity.content) + .status(status) + .altered_content(entity.altered_content) + .image_url(entity.image_url) + .created_at(entity.created_at.into()) + .modified_at(entity.modified_at.into()) + .build() + .map_err(Into::into) + } +} diff --git a/backend/todo-infra/src/database/postgres/todo/todo_repository_impl.rs b/backend/todo-infra/src/database/postgres/todo/todo_repository_impl.rs new file mode 100644 index 0000000..9c02f2b --- /dev/null +++ b/backend/todo-infra/src/database/postgres/todo/todo_repository_impl.rs @@ -0,0 +1,138 @@ +use async_trait::async_trait; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + +use super::todo_mapper::TodoMapper; +use super::{todo_entity, todo_item_entity}; +use crate::database::database_error_code::DatabaseErrorCode; +use domain::todo::models::{todo::Todo, todo_item::TodoItem}; +use domain::todo::repository::todo_repository::TodoRepository; + +pub struct TodoRepositoryImpl { + pub db: sea_orm::DatabaseConnection, +} + +impl TodoRepositoryImpl { + pub fn new(db: sea_orm::DatabaseConnection) -> Self { + Self { db } + } +} + +#[async_trait] +impl TodoRepository for TodoRepositoryImpl { + async fn find_todo_by_user_and_date( + &self, + user_id: i64, + date: chrono::NaiveDate, + ) -> Result, anyhow::Error> { + let todo = todo_entity::Entity::find() + .filter(todo_entity::Column::UserId.eq(user_id)) + .filter(todo_entity::Column::Date.eq(date)) + .one(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + let Some(todo) = todo else { + return Ok(None); + }; + + let items = todo_item_entity::Entity::find() + .filter(todo_item_entity::Column::TodoId.eq(todo.id)) + .all(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + let mapped = TodoMapper::map_entity_to_todo(todo, items)?; + + Ok(Some(mapped)) + } + + async fn insert_todo(&self, todo: &Todo) -> Result { + let active = TodoMapper::map_todo_to_entity(todo); + + let saved = active + .insert(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(TodoMapper::map_entity_to_todo(saved, vec![])?) + } + + async fn update_todo(&self, todo: &Todo) -> Result { + let active = TodoMapper::map_todo_to_entity(todo); + + let saved = active + .update(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + let items = todo_item_entity::Entity::find() + .filter(todo_item_entity::Column::TodoId.eq(saved.id)) + .all(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(TodoMapper::map_entity_to_todo(saved, items)?) + } + + async fn find_item_by_id(&self, item_id: i64) -> Result, anyhow::Error> { + let model = todo_item_entity::Entity::find_by_id(item_id) + .one(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(model + .map(|m| TodoMapper::map_entity_to_item(m)) + .transpose()?) + } + + async fn find_items_by_todo_id(&self, todo_id: i64) -> Result, anyhow::Error> { + let models = todo_item_entity::Entity::find() + .filter(todo_item_entity::Column::TodoId.eq(todo_id)) + .all(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + let items = models + .into_iter() + .map(TodoMapper::map_entity_to_item) + .collect::, _>>()?; + + Ok(items) + } + + async fn insert_item( + &self, + item: &TodoItem, + parent_todo_id: i64, + ) -> Result { + let mut model = TodoMapper::map_item_to_entity(item); + model.todo_id = Set(parent_todo_id); + + let saved = model + .insert(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + TodoMapper::map_entity_to_item(saved) + } + + async fn update_item(&self, item: &TodoItem) -> Result { + let active = TodoMapper::map_item_to_entity(item); + + let saved = active + .update(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + TodoMapper::map_entity_to_item(saved) + } + + async fn delete_item(&self, item_id: i64) -> Result<(), anyhow::Error> { + todo_item_entity::Entity::delete_by_id(item_id) + .exec(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(()) + } +} diff --git a/backend/todo-infra/src/database/postgres/user/mod.rs b/backend/todo-infra/src/database/postgres/user/mod.rs new file mode 100644 index 0000000..049f7b6 --- /dev/null +++ b/backend/todo-infra/src/database/postgres/user/mod.rs @@ -0,0 +1,6 @@ +pub mod social_account_entity; +pub mod social_account_mapper; +pub mod social_account_repository_impl; +pub mod user_entity; +pub mod user_mapper; +pub mod user_repository_impl; diff --git a/backend/todo-infra/src/database/postgres/user/social_account_entity.rs b/backend/todo-infra/src/database/postgres/user/social_account_entity.rs new file mode 100644 index 0000000..fbcd12c --- /dev/null +++ b/backend/todo-infra/src/database/postgres/user/social_account_entity.rs @@ -0,0 +1,38 @@ +use crate::database::postgres::user::user_entity; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "social_accounts")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub user_id: i64, + pub provider: String, + pub provider_user_id: String, + pub created_at: DateTimeUtc, + pub modified_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Users, +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Relation::Users => Entity::belongs_to(user_entity::Entity) + .from(Column::UserId) + .to(user_entity::Column::Id) + .into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/todo-infra/src/database/postgres/user/social_account_mapper.rs b/backend/todo-infra/src/database/postgres/user/social_account_mapper.rs new file mode 100644 index 0000000..dd84f9f --- /dev/null +++ b/backend/todo-infra/src/database/postgres/user/social_account_mapper.rs @@ -0,0 +1,35 @@ +use crate::database::postgres::user::social_account_entity; +use domain::user::models::social_account::SocialAccountBuilder; +use domain::user::{OAuthProvider, SocialAccount}; +use sea_orm::{NotSet, Set}; +use std::str::FromStr; + +pub struct SocialAccountMapper; + +impl SocialAccountMapper { + pub fn map_to_entity(model: &SocialAccount) -> social_account_entity::ActiveModel { + social_account_entity::ActiveModel { + id: model.id().map(Set).unwrap_or(NotSet), + user_id: Set(model.user_id()), + provider: Set(model.provider().as_str().into()), + provider_user_id: Set(model.provider_user_id().to_string()), + created_at: Set(model.created_at().into()), + modified_at: Set(model.modified_at().into()), + } + } + + pub fn map_to_model( + entity: social_account_entity::Model, + ) -> Result { + let provider = OAuthProvider::from_str(entity.provider.as_str())?; + + Ok(SocialAccountBuilder::default() + .id(Some(entity.id)) + .user_id(entity.user_id) + .provider(provider) + .provider_user_id(entity.provider_user_id) + .created_at(entity.created_at.into()) + .modified_at(entity.modified_at.into()) + .build()?) + } +} diff --git a/backend/todo-infra/src/database/postgres/user/social_account_repository_impl.rs b/backend/todo-infra/src/database/postgres/user/social_account_repository_impl.rs new file mode 100644 index 0000000..e2ba766 --- /dev/null +++ b/backend/todo-infra/src/database/postgres/user/social_account_repository_impl.rs @@ -0,0 +1,65 @@ +use crate::database::database_error_code::DatabaseErrorCode; +use crate::database::postgres::user::social_account_entity; +use crate::database::postgres::user::social_account_mapper::SocialAccountMapper; +use anyhow::Error; +use async_trait::async_trait; +use domain::user::{SocialAccount, SocialAccountRepository}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; + +pub struct SocialAccountRepositoryImpl { + pub db: sea_orm::DatabaseConnection, +} + +impl SocialAccountRepositoryImpl { + pub fn new(db: sea_orm::DatabaseConnection) -> Self { + Self { db } + } +} + +#[async_trait] +impl SocialAccountRepository for SocialAccountRepositoryImpl { + async fn find_by_provider_and_user_id( + &self, + provider: domain::user::OAuthProvider, + provider_user_id: &str, + ) -> Result, anyhow::Error> { + let social_account = social_account_entity::Entity::find() + .filter(social_account_entity::Column::Provider.eq(provider.as_str().to_string())) + .filter(social_account_entity::Column::ProviderUserId.eq(provider_user_id)) + .one(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(social_account + .map(|s| SocialAccountMapper::map_to_model(s)) + .transpose()?) + } + + async fn find_by_user_id_and_provider( + &self, + user_id: i64, + provider: &domain::user::OAuthProvider, + ) -> Result, anyhow::Error> { + let social_account = social_account_entity::Entity::find() + .filter(social_account_entity::Column::UserId.eq(user_id)) + .filter(social_account_entity::Column::Provider.eq(provider.as_str().to_string())) + .one(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(social_account + .map(|s| SocialAccountMapper::map_to_model(s)) + .transpose()?) + } + + async fn insert(&self, social_account: &SocialAccount) -> Result { + let social_account = SocialAccountMapper::map_to_entity(social_account); + + let saved = social_account + .insert(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(SocialAccountMapper::map_to_model(saved)?) + } +} diff --git a/backend/todo-infra/src/database/postgres/user/user_entity.rs b/backend/todo-infra/src/database/postgres/user/user_entity.rs new file mode 100644 index 0000000..17f44b2 --- /dev/null +++ b/backend/todo-infra/src/database/postgres/user/user_entity.rs @@ -0,0 +1,34 @@ +use crate::database::postgres::user::social_account_entity; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub nickname: String, + pub profile_image_url: Option, + pub created_at: DateTimeUtc, + pub modified_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + SocialAccounts, +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Relation::SocialAccounts => Entity::has_many(social_account_entity::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::SocialAccounts.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/todo-infra/src/database/postgres/user/user_mapper.rs b/backend/todo-infra/src/database/postgres/user/user_mapper.rs new file mode 100644 index 0000000..e069270 --- /dev/null +++ b/backend/todo-infra/src/database/postgres/user/user_mapper.rs @@ -0,0 +1,27 @@ +use super::user_entity; +use domain::user::models::user::{User, UserBuilder}; +use sea_orm::{NotSet, Set}; + +pub struct UserMapper; + +impl UserMapper { + pub fn map_to_entity(model: &User) -> user_entity::ActiveModel { + user_entity::ActiveModel { + id: model.id().map(Set).unwrap_or(NotSet), + nickname: Set(model.nickname().to_string()), + profile_image_url: Set(model.profile_image_url().cloned()), + created_at: Set(model.created_at().into()), + modified_at: Set(model.modified_at().into()), + } + } + + pub fn map_to_model(entity: user_entity::Model) -> Result { + Ok(UserBuilder::default() + .id(Some(entity.id)) + .nickname(entity.nickname) + .profile_image_url(entity.profile_image_url) + .created_at(entity.created_at.into()) + .modified_at(entity.modified_at.into()) + .build()?) + } +} diff --git a/backend/todo-infra/src/database/postgres/user/user_repository_impl.rs b/backend/todo-infra/src/database/postgres/user/user_repository_impl.rs new file mode 100644 index 0000000..221b1da --- /dev/null +++ b/backend/todo-infra/src/database/postgres/user/user_repository_impl.rs @@ -0,0 +1,73 @@ +use anyhow::Error; +use async_trait::async_trait; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; + +use domain::user::models::user::User; +use domain::user::repository::user_repository::UserRepository; + +use crate::database::database_error_code::DatabaseErrorCode; +use crate::database::postgres::user::user_entity; +use crate::database::postgres::user::user_mapper::UserMapper; + +pub struct UserRepositoryImpl { + pub db: sea_orm::DatabaseConnection, +} + +impl UserRepositoryImpl { + pub fn new(db: sea_orm::DatabaseConnection) -> Self { + Self { db } + } +} + +#[async_trait] +impl UserRepository for UserRepositoryImpl { + async fn find_by_id(&self, id: i64) -> Result, Error> { + let user = user_entity::Entity::find_by_id(id) + .one(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(user.map(|u| UserMapper::map_to_model(u)).transpose()?) + } + + async fn find_by_nickname(&self, nickname: &str) -> Result, Error> { + let user = user_entity::Entity::find() + .filter(user_entity::Column::Nickname.eq(nickname)) + .one(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(user.map(|u| UserMapper::map_to_model(u)).transpose()?) + } + + async fn insert(&self, user: &mut User) -> Result { + let user = UserMapper::map_to_entity(user); + + let saved = user + .insert(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(UserMapper::map_to_model(saved)?) + } + + async fn update(&self, user: &User) -> Result { + let user = UserMapper::map_to_entity(user); + + let updated = user + .update(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(UserMapper::map_to_model(updated)?) + } + + async fn delete(&self, id: i64) -> Result<(), Error> { + user_entity::Entity::delete_by_id(id) + .exec(&self.db) + .await + .map_err(DatabaseErrorCode::QueryError)?; + + Ok(()) + } +}