From 6293a542f75114541becba6fc44687d46d2525c9 Mon Sep 17 00:00:00 2001 From: Kavika Date: Wed, 13 Nov 2024 01:51:03 +1100 Subject: [PATCH] Answer CRUD --- backend/server/src/handler/answer.rs | 89 ++++++ backend/server/src/handler/mod.rs | 1 + backend/server/src/main.rs | 15 +- backend/server/src/models/answer.rs | 323 +++++++++++++++++++++- backend/server/src/models/auth.rs | 87 +++++- backend/server/src/models/question.rs | 2 +- backend/server/src/service/answer.rs | 31 +++ backend/server/src/service/application.rs | 27 ++ backend/server/src/service/mod.rs | 1 + 9 files changed, 561 insertions(+), 15 deletions(-) create mode 100644 backend/server/src/handler/answer.rs create mode 100644 backend/server/src/service/answer.rs diff --git a/backend/server/src/handler/answer.rs b/backend/server/src/handler/answer.rs new file mode 100644 index 00000000..7c1f4495 --- /dev/null +++ b/backend/server/src/handler/answer.rs @@ -0,0 +1,89 @@ +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde_json::json; +use crate::models::answer::{Answer, NewAnswer}; +use crate::models::app::AppState; +use crate::models::auth::{AnswerOwner, ApplicationOwner, AuthUser}; +use crate::models::error::ChaosError; +use crate::models::transaction::DBTransaction; + +pub struct AnswerHandler; + +impl AnswerHandler { + pub async fn create( + State(state): State, + Path(path): Path, + user: AuthUser, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + let id = Answer::create( + user.user_id, + data.application_id, + data.question_id, + data.answer_data, + state.snowflake_generator, + &mut transaction.tx + ).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(json!({"id": id})))) + } + + pub async fn get_all_common_by_application( + Path(application_id): Path, + _owner: ApplicationOwner, + mut transaction: DBTransaction<'_>, + ) -> Result { + let answers = Answer::get_all_common_by_application(application_id, &mut transaction.tx) + .await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(answers))) + } + + pub async fn get_all_by_application_and_role( + Path((application_id, role_id)): Path<(i64, i64)>, + _owner: ApplicationOwner, + mut transaction: DBTransaction<'_>, + ) -> Result { + let answers = Answer::get_all_by_application_and_role(application_id, role_id, &mut transaction.tx) + .await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(answers))) + } + + pub async fn update( + Path(answer_id): Path, + _owner: AnswerOwner, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Answer::update( + answer_id, + data.answer_data, + &mut transaction.tx + ).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully updated answer")) + } + + pub async fn delete( + Path(answer_id): Path, + _owner: AnswerOwner, + mut transaction: DBTransaction<'_> + ) -> Result { + Answer::delete(answer_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully deleted answer")) + } +} \ No newline at end of file diff --git a/backend/server/src/handler/mod.rs b/backend/server/src/handler/mod.rs index ebe08c27..dbcb3ee7 100644 --- a/backend/server/src/handler/mod.rs +++ b/backend/server/src/handler/mod.rs @@ -6,3 +6,4 @@ pub mod rating; pub mod role; pub mod application; pub mod question; +pub mod answer; diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index 156e8f1a..bd675cf2 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -3,6 +3,7 @@ use handler::user::UserHandler; use crate::handler::campaign::CampaignHandler; use crate::handler::organisation::OrganisationHandler; use crate::handler::application::ApplicationHandler; +use crate::handler::answer::AnswerHandler; use crate::models::storage::Storage; use anyhow::Result; use axum::routing::{get, patch, post, put}; @@ -68,7 +69,7 @@ async fn main() -> Result<()> { }; let app = Router::new() - .route("/", get(|| async { "Hello, World!" })) + .route("/", get(|| async { "Join DevSoc! https://devsoc.app/" })) .route("/api/auth/callback/google", get(google_callback)) .route("/api/v1/user", get(UserHandler::get)) .route("/api/v1/user/name", patch(UserHandler::update_name)) @@ -154,7 +155,7 @@ async fn main() -> Result<()> { ) .route("/api/v1/campaign", get(CampaignHandler::get_all)) .route("/api/v1/campaign/:campaign_id/question", post(QuestionHandler::create)) - .route("/api/v1/campaign/:campaign_id/question/:id", put(QuestionHandler::update).delete(QuestionHandler::delete)) + .route("/api/v1/campaign/:campaign_id/question/:id", patch(QuestionHandler::update).delete(QuestionHandler::delete)) .route("/api/v1/campaign/:campaign_id/questions/common", get(QuestionHandler::get_all_common_by_campaign)) .route( "/api/v1/campaign/:campaign_id/banner", @@ -163,9 +164,13 @@ async fn main() -> Result<()> { .route("api/v1/campaign/:campaign_id/application", post(CampaignHandler::create_application) ) - .route("api/v1/application/:application_id", get(ApplicationHandler::get)) - .route("api/v1/application/:application_id/status", patch(ApplicationHandler::set_status)) - .route("api/v1/application/:application_id/private", patch(ApplicationHandler::set_private_status)) + .route("/api/v1/application/:application_id", get(ApplicationHandler::get)) + .route("/api/v1/application/:application_id/status", patch(ApplicationHandler::set_status)) + .route("/api/v1/application/:application_id/private", patch(ApplicationHandler::set_private_status)) + .route("/api/v1/application/:application_id/answers/common", get(AnswerHandler::get_all_common_by_application)) + .route("/api/v1/application/:applicaiton_id/answer", post(AnswerHandler::create)) + .route("/api/v1/application/:application_id/answers/role/:role_id", get(AnswerHandler::get_all_by_application_and_role)) + .route("/api/v1/answer/:answer_id", patch(AnswerHandler::update).delete(AnswerHandler::delete)) .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); diff --git a/backend/server/src/models/answer.rs b/backend/server/src/models/answer.rs index 7a674703..64056bc2 100644 --- a/backend/server/src/models/answer.rs +++ b/backend/server/src/models/answer.rs @@ -1,7 +1,10 @@ +use std::ops::DerefMut; use crate::models::error::ChaosError; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{Pool, Postgres}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{Pool, Postgres, Transaction}; +use crate::models::question::{MultiOptionData, MultiOptionQuestionOption, QuestionData, QuestionType, QuestionTypeParent}; /// The `Answer` type that will be sent in API responses. /// @@ -11,7 +14,6 @@ use sqlx::{Pool, Postgres}; /// ```json /// { /// "id": 7233828375289773948, -/// "application_id": 7233828375289125398, /// "question_id": 7233828375289139200, /// "answer_type": "MultiChoice", /// "data": 7233828393325384908, @@ -22,7 +24,6 @@ use sqlx::{Pool, Postgres}; #[derive(Deserialize, Serialize)] pub struct Answer { id: i64, - application_id: i64, question_id: i64, #[serde(flatten)] @@ -32,6 +33,253 @@ pub struct Answer { updated_at: DateTime, } +#[derive(Deserialize)] +pub struct NewAnswer { + pub application_id: i64, + pub question_id: i64, + + #[serde(flatten)] + pub answer_data: AnswerData +} + +#[derive(Deserialize, sqlx::FromRow)] +pub struct AnswerRawData { + id: i64, + question_id: i64, + question_type: QuestionType, + short_answer_answer: Option, + multi_option_answers: Option>, + ranking_answers: Option>, + created_at: DateTime, + updated_at: DateTime, +} + +#[derive(Deserialize)] +pub struct AnswerTypeApplicationId { + question_type: QuestionType, + application_id: i64, +} + +impl Answer { + pub async fn create(user_id: i64, application_id: i64, question_id: i64, answer_data: AnswerData, mut snowflake_generator: SnowflakeIdGenerator, transaction: &mut Transaction<'_, Postgres>) -> Result { + answer_data.validate()?; + + let id = snowflake_generator.generate(); + + sqlx::query!( + " + INSERT INTO answers (id, application_id, question_id) + VALUES ($1, $2, $3) + ", + id, application_id, question_id + ) + .execute(transaction.deref_mut()) + .await?; + + answer_data.insert_into_db(id, transaction).await?; + + Ok(id) + } + + pub async fn get(id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result { + let answer_raw_data: AnswerRawData = sqlx::query_as( + " + SELECT + a.id, + a.question_id, + q.question_type AS \"question_type: QuestionType\", + a.created_at, + a.updated_at, + saa.text AS short_answer_answer, + array_agg( + moao.option_id + ) multi_option_answers, + array_agg( + rar.option_id ORDER BY rar.rank + ) ranking_answers + FROM + answers a + JOIN questions q ON a.question_id = q.id + LEFT JOIN + multi_option_answer_options moao ON moao.answer_id = a.id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown') + + LEFT JOIN + short_answer_answers saa ON saa.answer_id = a.id + AND q.question_type = 'ShortAnswer' + + LEFT JOIN + ranking_answer_rankings rar ON rar.answer_id = a.id + AND q.question_type = 'Ranking' + WHERE q.id = $1 + GROUP BY + a.id + " + ) + .bind(id) + .fetch_one(transaction.deref_mut()) + .await?; + + let answer_data = AnswerData::from_answer_raw_data(answer_raw_data.question_type, answer_raw_data.short_answer_answer, answer_raw_data.multi_option_answers, answer_raw_data.ranking_answers); + + Ok( + Answer { + id, + question_id: answer_raw_data.question_id, + answer_data, + created_at: answer_raw_data.created_at, + updated_at: answer_raw_data.updated_at, + } + ) + } + + pub async fn get_all_common_by_application(application_id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result, ChaosError> { + let answer_raw_data: Vec = sqlx::query_as( + " + SELECT + a.id, + a.question_id, + q.question_type AS \"question_type: QuestionType\", + a.created_at, + a.updated_at, + saa.text AS short_answer_answer, + array_agg( + moao.option_id + ) multi_option_answers, + array_agg( + rar.option_id ORDER BY rar.rank + ) ranking_answers + FROM + answers a + JOIN questions q ON a.question_id = q.id + LEFT JOIN + multi_option_answer_options moao ON moao.answer_id = a.id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown') + + LEFT JOIN + short_answer_answers saa ON saa.answer_id = a.id + AND q.question_type = 'ShortAnswer' + + LEFT JOIN + ranking_answer_rankings rar ON rar.answer_id = a.id + AND q.question_type = 'Ranking' + WHERE a.application_id = $1 AND q.common = true + GROUP BY + a.id + " + ) + .bind(application_id) + .fetch_all(transaction.deref_mut()) + .await?; + + let answers = answer_raw_data.into_iter().map(|answer_raw_data| { + let answer_data = AnswerData::from_answer_raw_data(answer_raw_data.question_type, answer_raw_data.short_answer_answer, answer_raw_data.multi_option_answers, answer_raw_data.ranking_answers); + + Answer { + id: answer_raw_data.id, + question_id: answer_raw_data.question_id, + answer_data, + created_at: answer_raw_data.created_at, + updated_at: answer_raw_data.updated_at, + } + }).collect(); + + Ok(answers) + } + + pub async fn get_all_by_application_and_role(application_id: i64, role_id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result, ChaosError> { + let answer_raw_data: Vec = sqlx::query_as( + " + SELECT + a.id, + a.question_id, + q.question_type AS \"question_type: QuestionType\", + a.created_at, + a.updated_at, + saa.text AS short_answer_answer, + array_agg( + moao.option_id + ) multi_option_answers, + array_agg( + rar.option_id ORDER BY rar.rank + ) ranking_answers + FROM + answers a + JOIN questions q ON a.question_id = q.id + JOIN question_roles qr ON q.id = qr.question_id + LEFT JOIN + multi_option_answer_options moao ON moao.answer_id = a.id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown') + + LEFT JOIN + short_answer_answers saa ON saa.answer_id = a.id + AND q.question_type = 'ShortAnswer' + + LEFT JOIN + ranking_answer_rankings rar ON rar.answer_id = a.id + AND q.question_type = 'Ranking' + WHERE a.application_id = $1 AND qr.role_id = $2 AND q.common = true + GROUP BY + a.id + " + ) + .bind(application_id) + .bind(role_id) + .fetch_all(transaction.deref_mut()) + .await?; + + let answers = answer_raw_data.into_iter().map(|answer_raw_data| { + let answer_data = AnswerData::from_answer_raw_data(answer_raw_data.question_type, answer_raw_data.short_answer_answer, answer_raw_data.multi_option_answers, answer_raw_data.ranking_answers); + + Answer { + id: answer_raw_data.id, + question_id: answer_raw_data.question_id, + answer_data, + created_at: answer_raw_data.created_at, + updated_at: answer_raw_data.updated_at, + } + }).collect(); + + Ok(answers) + } + + pub async fn update(id: i64, answer_data: AnswerData, transaction: &mut Transaction<'_, Postgres>) -> Result<(), ChaosError> { + answer_data.validate()?; + + let answer = sqlx::query_as!( + AnswerTypeApplicationId, + " + SELECT a.application_id, q.question_type AS \"question_type: QuestionType\" + FROM answers a + JOIN questions q ON a.question_id = q.id + WHERE a.id = $1 + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let old_data = AnswerData::from_question_type(&answer.question_type); + old_data.delete_from_db(id, transaction).await?; + + answer_data.insert_into_db(id, transaction).await?; + + sqlx::query!("UPDATE applications SET updated_at = $1 WHERE id = $2", Utc::now(), answer.application_id) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + + pub async fn delete(id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result<(), ChaosError> { + sqlx::query!("DELETE FROM answers WHERE id = $1 RETURNING id", id) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } +} + #[derive(Deserialize, Serialize)] #[serde( tag = "answer_type", content = "data")] pub enum AnswerData { @@ -43,6 +291,41 @@ pub enum AnswerData { } impl AnswerData { + fn from_question_type(question_type: &QuestionType) -> Self { + match question_type { + QuestionType::ShortAnswer => AnswerData::ShortAnswer("".to_string()), + QuestionType::MultiChoice => AnswerData::MultiChoice(0), + QuestionType::MultiSelect => AnswerData::MultiSelect(Vec::::new()), + QuestionType::DropDown => AnswerData::DropDown(0), + QuestionType::Ranking => AnswerData::Ranking(Vec::::new()), + } + } + + fn from_answer_raw_data(question_type: QuestionType, short_answer_answer: Option, multi_option_answers: Option>, ranking_answers: Option>) -> Self { + return if question_type == QuestionType::ShortAnswer { + let answer = short_answer_answer.expect("Data should exist for ShortAnswer variant"); + AnswerData::ShortAnswer(answer) + } else if + question_type == QuestionType::MultiChoice || + question_type == QuestionType::MultiSelect || + question_type == QuestionType::DropDown + { + let options = multi_option_answers.expect("Data should exist for MultiOptionData variants"); + + match question_type { + QuestionType::MultiChoice => AnswerData::MultiChoice(options[0]), + QuestionType::MultiSelect => AnswerData::MultiSelect(options), + QuestionType::DropDown => AnswerData::DropDown(options[0]), + _ => AnswerData::ShortAnswer("".to_string()) // Should never be reached, hence return ShortAnswer + } + } else if question_type == QuestionType::Ranking { + let options = ranking_answers.expect("Data should exist for Ranking variant"); + AnswerData::Ranking(options) + } else { + AnswerData::ShortAnswer("".to_string()) // Should never be reached, hence return ShortAnswer + } + } + pub fn validate(&self) -> Result<(), ChaosError> { match self { Self::ShortAnswer(text) => if text.len() == 0 { return Err(ChaosError::BadRequest) }, @@ -54,7 +337,7 @@ impl AnswerData { Ok(()) } - pub async fn insert_into_db(self, answer_id: i64, pool: &Pool) -> Result<(), ChaosError> { + pub async fn insert_into_db(self, answer_id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result<(), ChaosError> { match self { Self::ShortAnswer(text) => { sqlx::query!( @@ -62,7 +345,7 @@ impl AnswerData { text, answer_id ) - .execute(pool) + .execute(transaction.deref_mut()) .await?; Ok(()) @@ -74,7 +357,7 @@ impl AnswerData { option_id, answer_id ) - .execute(pool) + .execute(transaction.deref_mut()) .await?; Ok(()) @@ -87,7 +370,7 @@ impl AnswerData { }); let query = query_builder.build(); - query.execute(pool).await?; + query.execute(transaction.deref_mut()).await?; Ok(()) }, @@ -101,10 +384,34 @@ impl AnswerData { }); let query = query_builder.build(); - query.execute(pool).await?; + query.execute(transaction.deref_mut()).await?; Ok(()) } } } + + pub async fn delete_from_db(self, answer_id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer(_) => { + sqlx::query!("DELETE FROM short_answer_answers WHERE answer_id = $1", answer_id) + .execute(transaction.deref_mut()) + .await?; + }, + Self::MultiChoice(_) + | Self::MultiSelect(_) + | Self::DropDown(_) => { + sqlx::query!("DELETE FROM multi_option_answer_options WHERE answer_id = $1", answer_id) + .execute(transaction.deref_mut()) + .await?; + }, + Self::Ranking(_) => { + sqlx::query!("DELETE FROM ranking_answer_rankings WHERE answer_id = $1", answer_id) + .execute(transaction.deref_mut()) + .await?; + } + } + + Ok(()) + } } diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index 61e49f13..da6bc2e7 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use crate::models::app::AppState; use crate::models::error::ChaosError; -use crate::service::application::user_is_application_admin; +use crate::service::application::{user_is_application_admin, user_is_application_owner}; use crate::service::auth::is_super_user; use crate::service::campaign::user_is_campaign_admin; use crate::service::jwt::decode_auth_token; @@ -17,6 +17,7 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum::{async_trait, RequestPartsExt}; use axum_extra::{headers::Cookie, TypedHeader}; use serde::{Deserialize, Serialize}; +use crate::service::answer::user_is_answer_owner; use crate::service::question::user_is_question_admin; // tells the web framework how to take the url query params they will have @@ -497,4 +498,88 @@ where Ok(QuestionAdmin { user_id }) } +} + +pub struct ApplicationOwner { + pub user_id: i64, +} + +#[async_trait] +impl FromRequestParts for ApplicationOwner +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let decoding_key = &app_state.decoding_key; + let jwt_validator = &app_state.jwt_validator; + let TypedHeader(cookies) = parts + .extract::>() + .await + .map_err(|_| ChaosError::NotLoggedIn)?; + + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; + + let claims = + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; + + let pool = &app_state.db; + let user_id = claims.sub; + + let application_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("application_id") + .ok_or(ChaosError::BadRequest)?; + + user_is_application_owner(user_id, application_id, pool).await?; + + Ok(ApplicationOwner { user_id }) + } +} + +pub struct AnswerOwner { + pub user_id: i64, +} + +#[async_trait] +impl FromRequestParts for AnswerOwner +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let decoding_key = &app_state.decoding_key; + let jwt_validator = &app_state.jwt_validator; + let TypedHeader(cookies) = parts + .extract::>() + .await + .map_err(|_| ChaosError::NotLoggedIn)?; + + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; + + let claims = + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; + + let pool = &app_state.db; + let user_id = claims.sub; + + let application_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("application_id") + .ok_or(ChaosError::BadRequest)?; + + user_is_answer_owner(user_id, application_id, pool).await?; + + Ok(AnswerOwner { user_id }) + } } \ No newline at end of file diff --git a/backend/server/src/models/question.rs b/backend/server/src/models/question.rs index 695830ea..11bf7fea 100644 --- a/backend/server/src/models/question.rs +++ b/backend/server/src/models/question.rs @@ -406,7 +406,7 @@ pub enum QuestionType { #[derive(Deserialize)] pub struct QuestionTypeParent { - question_type: QuestionType + pub question_type: QuestionType } impl QuestionType { diff --git a/backend/server/src/service/answer.rs b/backend/server/src/service/answer.rs new file mode 100644 index 00000000..a9aa6d4b --- /dev/null +++ b/backend/server/src/service/answer.rs @@ -0,0 +1,31 @@ +use sqlx::{Pool, Postgres}; +use crate::models::error::ChaosError; + +pub async fn user_is_answer_owner( + user_id: i64, + answer_id: i64, + pool: &Pool +) -> Result<(), ChaosError> { + let is_owner = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM ( + SELECT FROM answers ans + JOIN applications app ON ans.application_id = app.id + WHERE ans.id = $1 AND app.user_id = $2 + ) + ) + ", + answer_id, user_id + ) + .fetch_one(pool) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_owner { + return Err(ChaosError::Unauthorized) + } + + Ok(()) +} \ No newline at end of file diff --git a/backend/server/src/service/application.rs b/backend/server/src/service/application.rs index 4fdf052b..67f2561a 100644 --- a/backend/server/src/service/application.rs +++ b/backend/server/src/service/application.rs @@ -33,4 +33,31 @@ pub async fn user_is_application_admin( Ok(()) +} + +pub async fn user_is_application_owner( + user_id: i64, + application_id: i64, + pool: &Pool +) -> Result<(), ChaosError> { + let is_owner = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM ( + SELECT FROM applications WHERE id = $1 AND user_id = $2 + ) + ) + ", + application_id, user_id + ) + .fetch_one(pool) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_owner { + return Err(ChaosError::Unauthorized) + } + + Ok(()) } \ No newline at end of file diff --git a/backend/server/src/service/mod.rs b/backend/server/src/service/mod.rs index 1c4e50fc..2754edd7 100644 --- a/backend/server/src/service/mod.rs +++ b/backend/server/src/service/mod.rs @@ -7,3 +7,4 @@ pub mod rating; pub mod role; pub mod application; pub mod question; +pub mod answer;