diff --git a/server/Cargo.lock b/server/Cargo.lock index 4db874ef..9c5d478b 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -934,6 +934,7 @@ dependencies = [ "mockall", "proptest", "proptest-derive", + "regex", "serde", "serde_json", "strum", @@ -4165,12 +4166,13 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" name = "types" version = "0.1.0" dependencies = [ - "async-trait", "common", "deriving_via", + "errors", "proptest", "proptest-derive", "serde", + "serde_json", "uuid", ] diff --git a/server/Makefile.toml b/server/Makefile.toml index c573ec06..df03ba2d 100644 --- a/server/Makefile.toml +++ b/server/Makefile.toml @@ -14,11 +14,18 @@ args = ["migrate", "generate", "${@}"] command = "cargo" args = ["clippy", "--fix", "--allow-dirty", "--allow-staged"] -[tasks.test] +[tasks.doctest] +command = "cargo" +args = ["test", "--doc"] + +[tasks.nextest] install_crate = { crate_name = "cargo-nextst" } command = "cargo" args = ["nextest", "run"] +[tasks.test] +dependencies = ["nextest", "doctest"] + [tasks.lint] command = "cargo" args = ["clippy", "--", "-D", "warnings"] diff --git a/server/domain/Cargo.toml b/server/domain/Cargo.toml index 72193999..5c5e14be 100644 --- a/server/domain/Cargo.toml +++ b/server/domain/Cargo.toml @@ -18,6 +18,7 @@ typed-builder = "0.20.0" types = { path = "../types" } uuid = { workspace = true } tokio = { workspace = true } +regex = { workspace = true } [dev-dependencies] chrono = { workspace = true, features = ["arbitrary"] } diff --git a/server/domain/src/form.rs b/server/domain/src/form.rs index c446ac88..0ea3b337 100644 --- a/server/domain/src/form.rs +++ b/server/domain/src/form.rs @@ -1 +1,6 @@ +pub mod answer; +pub mod comment; +pub mod message; pub mod models; +pub mod question; +pub mod service; diff --git a/server/domain/src/form/answer.rs b/server/domain/src/form/answer.rs new file mode 100644 index 00000000..f422fa96 --- /dev/null +++ b/server/domain/src/form/answer.rs @@ -0,0 +1,3 @@ +pub mod models; +pub mod service; +pub mod settings; diff --git a/server/domain/src/form/answer/models.rs b/server/domain/src/form/answer/models.rs new file mode 100644 index 00000000..b64ffcc5 --- /dev/null +++ b/server/domain/src/form/answer/models.rs @@ -0,0 +1,91 @@ +use chrono::{DateTime, Utc}; +use derive_getters::Getters; +use deriving_via::DerivingVia; +#[cfg(test)] +use proptest_derive::Arbitrary; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use types::non_empty_string::NonEmptyString; + +use crate::{ + form::{models::FormId, question::models::QuestionId}, + user::models::User, +}; + +pub type AnswerId = types::Id; + +#[derive(Clone, DerivingVia, Default, Debug, PartialEq)] +#[deriving(From, Into, IntoInner, Serialize(via: Option::), Deserialize(via: Option:: +))] +pub struct AnswerTitle(Option); + +impl AnswerTitle { + pub fn new(title: Option) -> Self { + Self(title) + } +} + +#[derive(Serialize, Deserialize, Getters, PartialEq, Debug)] +pub struct AnswerEntry { + id: AnswerId, + user: User, + timestamp: DateTime, + form_id: FormId, + title: AnswerTitle, +} + +impl AnswerEntry { + /// [`AnswerEntry`] を新しく作成します。 + /// + /// この関数が pub(crate) になっているのは、 + /// [`AnswerEntry`] というドメインモデルは [`FormSettings`] の状態によって + /// 作成できるか否かが変わるためです。 + /// このため、この関数が pub であると、Invalid な状態の [`AnswerEntry`] が作成される可能性あり、 + /// [`AnswerEntry`] を作成する処理は DomainService 側に委譲するために pub(crate) にしています。 + pub fn new(user: User, form_id: FormId, title: AnswerTitle) -> Self { + Self { + id: AnswerId::new(), + user, + timestamp: Utc::now(), + form_id, + title, + } + } + + /// [`AnswerEntry`] の各フィールドを指定して新しく作成します。 + /// + /// # Safety + /// この関数はオブジェクトを新しく作成しない場合 + /// (例えば、データベースから取得した場合)にのみ使用してください。 + pub unsafe fn from_raw_parts( + id: AnswerId, + user: User, + timestamp: DateTime, + form_id: FormId, + title: AnswerTitle, + ) -> Self { + Self { + id, + user, + timestamp, + form_id, + title, + } + } +} + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct FormAnswerContent { + pub answer_id: AnswerId, + pub question_id: QuestionId, + pub answer: String, +} + +pub type AnswerLabelId = types::IntegerId; + +#[cfg_attr(test, derive(Arbitrary))] +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct AnswerLabel { + pub id: AnswerLabelId, + pub name: String, +} diff --git a/server/domain/src/form/answer/service.rs b/server/domain/src/form/answer/service.rs new file mode 100644 index 00000000..3f11082e --- /dev/null +++ b/server/domain/src/form/answer/service.rs @@ -0,0 +1,47 @@ +use async_trait::async_trait; +use chrono::Utc; +use errors::{domain::DomainError, Error}; + +use crate::types::verified::{Verified, Verifier}; +use crate::{ + form::{ + answer::models::{AnswerEntry, AnswerTitle}, + models::{FormId, Visibility}, + }, + repository::form::form_repository::FormRepository, + user::models::User, +}; + +pub struct PostAnswerEntriesVerifier<'a, FormRepo: FormRepository> { + pub form_repo: &'a FormRepo, + pub actor: &'a User, + pub answer_entry: AnswerEntry, +} + +#[async_trait] +impl Verifier for PostAnswerEntriesVerifier<'_, FormRepo> { + async fn verify(self) -> Result, Error> { + let target = self.answer_entry; + + let form = self + .form_repo + .get(*target.form_id()) + .await? + .ok_or(DomainError::NotFound)? + .try_into_read(self.actor)?; + + let form_settings = form.settings(); + + let is_public_form = *form_settings.visibility() == Visibility::PUBLIC; + let is_within_period = form_settings + .answer_settings() + .response_period() + .is_within_period(Utc::now()); + + if is_public_form && is_within_period { + Ok(Self::new(target)) + } else { + Err(Error::from(DomainError::Forbidden)) + } + } +} diff --git a/server/domain/src/form/answer/settings.rs b/server/domain/src/form/answer/settings.rs new file mode 100644 index 00000000..e072fd8b --- /dev/null +++ b/server/domain/src/form/answer/settings.rs @@ -0,0 +1 @@ +pub mod models; diff --git a/server/domain/src/form/answer/settings/models.rs b/server/domain/src/form/answer/settings/models.rs new file mode 100644 index 00000000..bf8e9c3d --- /dev/null +++ b/server/domain/src/form/answer/settings/models.rs @@ -0,0 +1,103 @@ +use chrono::{DateTime, Utc}; +#[cfg(test)] +use common::test_utils::arbitrary_opt_date_time; +use derive_getters::Getters; +use deriving_via::DerivingVia; +use errors::domain::DomainError; +#[cfg(test)] +use proptest_derive::Arbitrary; +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumString}; +use types::non_empty_string::NonEmptyString; + +#[cfg_attr(test, derive(Arbitrary))] +#[derive(Clone, DerivingVia, Default, Debug, PartialEq)] +#[deriving(From, Into, IntoInner, Serialize(via: Option::), Deserialize(via: Option:: +))] +pub struct DefaultAnswerTitle(Option); + +impl DefaultAnswerTitle { + pub fn new(title: Option) -> Self { + Self(title) + } +} + +#[cfg_attr(test, derive(Arbitrary))] +#[derive( + Serialize, Deserialize, Debug, EnumString, Display, Copy, Clone, Default, PartialOrd, PartialEq, +)] +pub enum AnswerVisibility { + PUBLIC, + #[default] + PRIVATE, +} + +impl TryFrom for AnswerVisibility { + type Error = DomainError; + + fn try_from(value: String) -> Result { + use std::str::FromStr; + Self::from_str(&value).map_err(Into::into) + } +} + +#[cfg_attr(test, derive(Arbitrary))] +#[derive(Serialize, Deserialize, Getters, Default, Debug, PartialEq)] +pub struct ResponsePeriod { + #[cfg_attr(test, proptest(strategy = "arbitrary_opt_date_time()"))] + #[serde(default)] + start_at: Option>, + #[cfg_attr(test, proptest(strategy = "arbitrary_opt_date_time()"))] + #[serde(default)] + end_at: Option>, +} + +impl ResponsePeriod { + pub fn try_new( + start_at: Option>, + end_at: Option>, + ) -> Result { + match (start_at, end_at) { + (Some(start_at), Some(end_at)) if start_at > end_at => { + Err(DomainError::InvalidResponsePeriod) + } + _ => Ok(Self { start_at, end_at }), + } + } + + pub fn is_within_period(&self, now: DateTime) -> bool { + if let Some(start_at) = self.start_at { + if start_at > now { + return false; + } + } + if let Some(end_at) = self.end_at { + if end_at < now { + return false; + } + } + true + } +} + +#[cfg_attr(test, derive(Arbitrary))] +#[derive(Serialize, Deserialize, Getters, Default, Debug, PartialEq)] +pub struct AnswerSettings { + default_answer_title: DefaultAnswerTitle, + visibility: AnswerVisibility, + response_period: ResponsePeriod, +} + +impl AnswerSettings { + pub fn new( + default_answer_title: DefaultAnswerTitle, + visibility: AnswerVisibility, + response_period: ResponsePeriod, + ) -> Self { + Self { + default_answer_title, + visibility, + response_period, + } + } +} diff --git a/server/domain/src/form/comment.rs b/server/domain/src/form/comment.rs new file mode 100644 index 00000000..e072fd8b --- /dev/null +++ b/server/domain/src/form/comment.rs @@ -0,0 +1 @@ +pub mod models; diff --git a/server/domain/src/form/comment/models.rs b/server/domain/src/form/comment/models.rs new file mode 100644 index 00000000..de538d1f --- /dev/null +++ b/server/domain/src/form/comment/models.rs @@ -0,0 +1,56 @@ +use chrono::{DateTime, Utc}; +use derive_getters::Getters; +use deriving_via::DerivingVia; +use serde::{Deserialize, Serialize}; +use types::non_empty_string::NonEmptyString; + +use crate::{form::answer::models::AnswerId, user::models::User}; + +pub type CommentId = types::Id; + +#[derive(DerivingVia, Debug, PartialEq)] +#[deriving(Clone, From, Into, IntoInner, Serialize, Deserialize)] +pub struct CommentContent(NonEmptyString); + +impl CommentContent { + pub fn new(content: NonEmptyString) -> Self { + Self(content) + } +} + +#[derive(Serialize, Deserialize, Getters, Debug, PartialEq)] +pub struct Comment { + answer_id: AnswerId, + comment_id: CommentId, + content: CommentContent, + timestamp: DateTime, + commented_by: User, +} + +impl Comment { + pub fn new(answer_id: AnswerId, content: CommentContent, commented_by: User) -> Self { + Self { + answer_id, + comment_id: CommentId::new(), + content, + timestamp: Utc::now(), + commented_by, + } + } + + pub fn from_raw_parts( + answer_id: AnswerId, + comment_id: CommentId, + content: CommentContent, + timestamp: DateTime, + commented_by: User, + ) -> Self { + Self { + answer_id, + comment_id, + content, + timestamp, + commented_by, + } + } +} diff --git a/server/domain/src/form/message.rs b/server/domain/src/form/message.rs new file mode 100644 index 00000000..e072fd8b --- /dev/null +++ b/server/domain/src/form/message.rs @@ -0,0 +1 @@ +pub mod models; diff --git a/server/domain/src/form/message/models.rs b/server/domain/src/form/message/models.rs new file mode 100644 index 00000000..2d8f0a48 --- /dev/null +++ b/server/domain/src/form/message/models.rs @@ -0,0 +1,346 @@ +use chrono::{DateTime, Utc}; +use derive_getters::Getters; +use errors::domain::DomainError; + +use crate::{ + form::answer::models::AnswerEntry, + types::authorization_guard::AuthorizationGuardDefinitions, + user::models::{Role::Administrator, User}, +}; + +pub type MessageId = types::Id; + +#[derive(Getters, PartialEq, Debug)] +pub struct Message { + id: MessageId, + related_answer: AnswerEntry, + sender: User, + body: String, + timestamp: DateTime, +} + +impl AuthorizationGuardDefinitions for Message { + /// [`Message`] の作成権限があるかどうかを判定します。 + /// + /// 作成権限は以下の条件のどちらかを満たしている場合に与えられます。 + /// - [`actor`] が [`Administrator`] である場合 + /// - [`actor`] が関連する回答の回答者である場合 + /// + /// # Examples + /// ``` + /// use domain::{ + /// form::{answer::models::AnswerEntry, message::models::Message}, + /// types::authorization_guard::AuthorizationGuardDefinitions, + /// user::models::{Role, User}, + /// }; + /// use uuid::Uuid; + /// + /// let respondent = User { + /// name: "respondent".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// let related_answer = AnswerEntry::new( + /// respondent.to_owned(), + /// Default::default(), + /// Default::default(), + /// ); + /// + /// let message = Message::try_new( + /// related_answer, + /// respondent.to_owned(), + /// "test message".to_string(), + /// ) + /// .unwrap(); + /// + /// let administrator = User { + /// name: "administrator".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::Administrator, + /// }; + /// + /// let unrelated_standard_user = User { + /// name: "unrelated_user".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// assert!(message.can_create(&respondent)); + /// assert!(message.can_create(&administrator)); + /// assert!(!message.can_create(&unrelated_standard_user)); + /// ``` + fn can_create(&self, actor: &User) -> bool { + actor.role == Administrator + || (actor.id == self.sender.id && self.related_answer.user().id == self.sender.id) + } + + /// [`Message`] の読み取り権限があるかどうかを判定します。 + /// + /// 読み取り権限は以下の条件のどちらかを満たしている場合に与えられます。 + /// - [`actor`] が [`Administrator`] である場合 + /// - [`actor`] が関連する回答の回答者である場合 + /// + /// # Examples + /// ``` + /// use domain::{ + /// form::{answer::models::AnswerEntry, message::models::Message}, + /// types::authorization_guard::AuthorizationGuardDefinitions, + /// user::models::{Role, User}, + /// }; + /// use uuid::Uuid; + /// + /// let respondent = User { + /// name: "respondent".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// let related_answer = AnswerEntry::new( + /// respondent.to_owned(), + /// Default::default(), + /// Default::default(), + /// ); + /// + /// let message = Message::try_new( + /// related_answer, + /// respondent.to_owned(), + /// "test message".to_string(), + /// ) + /// .unwrap(); + /// + /// let administrator = User { + /// name: "administrator".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::Administrator, + /// }; + /// + /// let unrelated_standard_user = User { + /// name: "unrelated_user".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// assert!(message.can_read(&respondent)); + /// assert!(message.can_read(&administrator)); + /// assert!(!message.can_read(&unrelated_standard_user)); + /// ``` + fn can_read(&self, actor: &User) -> bool { + actor.role == Administrator || self.related_answer.user().id == actor.id + } + + /// [`Message`] の更新権限があるかどうかを判定します。 + /// + /// 更新権限は以下の条件を満たしている場合に与えられます。 + /// - [`actor`] がメッセージの送信者の場合 + /// + /// [`actor`] が [`Administrator`] である場合に更新権限が与えられないのは、 + /// メッセージの送信者が意図しない更新が行われることを防ぐためです。 + /// + /// # Examples + /// ``` + /// use domain::{ + /// form::{answer::models::AnswerEntry, message::models::Message}, + /// types::authorization_guard::AuthorizationGuardDefinitions, + /// user::models::{Role, User}, + /// }; + /// use uuid::Uuid; + /// + /// let respondent = User { + /// name: "respondent".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// let related_answer = AnswerEntry::new( + /// respondent.to_owned(), + /// Default::default(), + /// Default::default(), + /// ); + /// + /// let message = Message::try_new( + /// related_answer, + /// respondent.to_owned(), + /// "test message".to_string(), + /// ) + /// .unwrap(); + /// + /// let administrator = User { + /// name: "administrator".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::Administrator, + /// }; + /// + /// let unrelated_standard_user = User { + /// name: "unrelated_user".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// assert!(message.can_update(&respondent)); + /// assert!(!message.can_update(&administrator)); + /// assert!(!message.can_update(&unrelated_standard_user)); + /// ``` + fn can_update(&self, actor: &User) -> bool { + self.sender.id == actor.id + } + + /// [`Message`] の削除権限があるかどうかを判定します。 + /// + /// 削除権限は以下の条件を満たしている場合に与えられます。 + /// - [`actor`] がメッセージの送信者の場合 + /// + /// [`actor`] が [`Administrator`] である場合に更新権限が与えられないのは、 + /// メッセージの送信者が意図しない削除(メッセージ内容の改変)が行われることを防ぐためです。 + /// + /// # Examples + /// ``` + /// use domain::{ + /// form::{answer::models::AnswerEntry, message::models::Message}, + /// types::authorization_guard::AuthorizationGuardDefinitions, + /// user::models::{Role, User}, + /// }; + /// use uuid::Uuid; + /// + /// let respondent = User { + /// name: "respondent".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// let related_answer = AnswerEntry::new( + /// respondent.to_owned(), + /// Default::default(), + /// Default::default(), + /// ); + /// + /// let message = Message::try_new( + /// related_answer, + /// respondent.to_owned(), + /// "test message".to_string(), + /// ) + /// .unwrap(); + /// + /// let administrator = User { + /// name: "administrator".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::Administrator, + /// }; + /// + /// let unrelated_standard_user = User { + /// name: "unrelated_user".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// assert!(message.can_delete(&respondent)); + /// assert!(!message.can_delete(&administrator)); + /// assert!(!message.can_delete(&unrelated_standard_user)); + /// ``` + fn can_delete(&self, actor: &User) -> bool { + self.sender.id == actor.id + } +} + +impl Message { + /// [`Message`] の生成を試みます。 + /// + /// 以下の場合に失敗します。 + /// - [`body`] が空文字列の場合 + /// + /// # Examples + /// ``` + /// use domain::{ + /// form::{answer::models::AnswerEntry, message::models::Message}, + /// user::models::{Role, User}, + /// }; + /// use uuid::Uuid; + /// + /// let user = User { + /// name: "user".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// let related_answer = AnswerEntry::new(user.to_owned(), Default::default(), Default::default()); + /// + /// let success_message = + /// Message::try_new(related_answer, user.to_owned(), "test message".to_string()); + /// + /// let related_answer = AnswerEntry::new(user.to_owned(), Default::default(), Default::default()); + /// let message_with_empty_body = Message::try_new(related_answer, user, "".to_string()); + /// + /// assert!(success_message.is_ok()); + /// assert!(message_with_empty_body.is_err()); + /// ``` + pub fn try_new( + related_answer: AnswerEntry, + sender: User, + body: String, + ) -> Result { + if body.is_empty() { + return Err(DomainError::EmptyMessageBody); + } + + Ok(Self { + id: MessageId::new(), + related_answer, + sender, + body, + timestamp: Utc::now(), + }) + } + + /// [`Message`] の各フィールドの値を受け取り、[`Message`] を生成します。 + /// + /// # Examples + /// ``` + /// use chrono::{DateTime, Utc}; + /// use domain::{ + /// form::{ + /// answer::models::AnswerEntry, + /// message::models::{Message, MessageId}, + /// }, + /// user::models::{Role, User}, + /// }; + /// use uuid::Uuid; + /// + /// let user = User { + /// name: "user".to_string(), + /// id: Uuid::new_v4(), + /// role: Role::StandardUser, + /// }; + /// + /// let related_answer = AnswerEntry::new(user.to_owned(), Default::default(), Default::default()); + /// + /// unsafe { + /// let message = Message::from_raw_parts( + /// MessageId::new(), + /// related_answer, + /// user, + /// "test message".to_string(), + /// Utc::now(), + /// ); + /// } + /// ``` + /// + /// # Safety + /// この関数は [`Message`] のバリデーションをスキップするため、 + /// データベースからすでにバリデーションされているデータを読み出すときなど、 + /// データの信頼性が保証されている場合にのみ使用してください。 + pub unsafe fn from_raw_parts( + id: MessageId, + related_answer: AnswerEntry, + sender: User, + body: String, + timestamp: DateTime, + ) -> Self { + Self { + id, + related_answer, + sender, + body, + timestamp, + } + } +} diff --git a/server/domain/src/form/models.rs b/server/domain/src/form/models.rs index 211edd31..1e7e7460 100644 --- a/server/domain/src/form/models.rs +++ b/server/domain/src/form/models.rs @@ -1,108 +1,122 @@ use chrono::{DateTime, Utc}; #[cfg(test)] -use common::test_utils::{arbitrary_date_time, arbitrary_opt_date_time, arbitrary_with_size}; +use common::test_utils::{arbitrary_date_time, arbitrary_opt_date_time}; use derive_getters::Getters; use deriving_via::DerivingVia; use errors::domain::DomainError; #[cfg(test)] use proptest_derive::Arbitrary; +use regex::Regex; use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumString}; -use typed_builder::TypedBuilder; +use types::non_empty_string::NonEmptyString; use crate::{ + form::answer::settings::models::{ + AnswerSettings, AnswerVisibility, DefaultAnswerTitle, ResponsePeriod, + }, types::authorization_guard::AuthorizationGuardDefinitions, user::models::{Role::Administrator, User}, }; -pub type FormId = types::IntegerId
; +pub type FormId = types::Id; -#[derive(Deserialize, Debug)] -pub struct OffsetAndLimit { - #[serde(default)] - pub offset: Option, - #[serde(default)] - pub limit: Option, -} +#[cfg_attr(test, derive(Arbitrary))] +#[derive(Clone, DerivingVia, Debug, PartialOrd, PartialEq)] +#[deriving(From, Into, IntoInner, Serialize, Deserialize)] +pub struct FormTitle(NonEmptyString); -#[derive(Serialize, Debug)] -pub struct SimpleForm { - pub id: FormId, - pub title: FormTitle, - pub description: FormDescription, - pub response_period: ResponsePeriod, - pub labels: Vec