diff --git a/backend/server/src/models/question.rs b/backend/server/src/models/question.rs index 27eb5e85..697078b4 100644 --- a/backend/server/src/models/question.rs +++ b/backend/server/src/models/question.rs @@ -1,7 +1,8 @@ +use std::ops::DerefMut; use crate::models::error::ChaosError; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{Pool, Postgres, QueryBuilder, Row}; +use sqlx::{Postgres, QueryBuilder, Transaction}; use snowflake::SnowflakeIdGenerator; /// The `Question` type that will be sent in API responses. @@ -53,6 +54,179 @@ pub struct Question { updated_at: DateTime, } +#[derive(Deserialize, Serialize, sqlx::FromRow)] +pub struct QuestionRawData { + id: i64, + title: String, + description: Option, + common: bool, // Common question are shown at the start + required: bool, + + question_type: QuestionType, + multi_option_data: Option>>, + + created_at: DateTime, + updated_at: DateTime, +} + +impl Question { + pub async fn create(campaign_id: i64, title: String, description: Option, common: bool, required: bool, question_data: QuestionData, mut snowflake_generator: SnowflakeIdGenerator, transaction: &mut Transaction<'_, Postgres>) -> Result { + question_data.validate()?; + + let id = snowflake_generator.generate(); + + sqlx::query!( + " + INSERT INTO questions ( + id, title, description, common, + required, question_type, campaign_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ", + id, title, description, common, required, QuestionType::from_question_data(&question_data) as QuestionType, campaign_id + ) + .execute(transaction.deref_mut()) + .await?; + + question_data.insert_into_db(id, transaction, snowflake_generator).await?; + + Ok(id) + } + + pub async fn get(id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result { + let question_raw_data: QuestionRawData = sqlx::query_as( + " + SELECT + q.id, + q.title, + q.description, + q.common, + q.required, + q.question_type AS \"question_type: QuestionType\", + q.created_at, + q.updated_at, + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) AS \"multi_option_data: Option>>\" + FROM + questions q + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.id = $1 + GROUP BY + q.id + " + ) + .bind(id) + .fetch_one(transaction.deref_mut()) + .await?; + + let question_data = QuestionData::from_question_raw_data(question_raw_data.question_type, question_raw_data.multi_option_data); + + Ok( + Question { + id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + } + ) + } + + pub async fn get_all_by_campaign(campaign_id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result, ChaosError> { + let question_raw_data: Vec = sqlx::query_as( + " + SELECT + q.id, + q.title, + q.description, + q.common, + q.required, + q.question_type AS \"question_type: QuestionType\", + q.created_at, + q.updated_at, + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) AS \"multi_option_data: Option>>\" + FROM + questions q + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.campaign_id = $1 + GROUP BY + q.id + " + ) + .bind(campaign_id) + .fetch_all(transaction.deref_mut()) + .await?; + + let questions = question_raw_data.into_iter().map(|question_raw_data| { + let question_data = QuestionData::from_question_raw_data(question_raw_data.question_type, question_raw_data.multi_option_data); + + Question { + id: question_raw_data.id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + } + }).collect(); + + Ok(questions) + } + + pub async fn update(id: i64, title: String, description: Option, common: bool, required: bool, question_data: QuestionData, transaction: &mut Transaction<'_, Postgres>, snowflake_generator: SnowflakeIdGenerator) -> Result<(), ChaosError> { + question_data.validate()?; + + let question_type_parent: QuestionTypeParent = sqlx::query_as!( + QuestionTypeParent, + " + UPDATE questions SET + title = $2, description = $3, common = $4, + required = $5, question_type = $6, updated_at = $7 + WHERE id = $1 + RETURNING question_type AS \"question_type: QuestionType\" + ", + id, title, description, common, required, + QuestionType::from_question_data(&question_data) as QuestionType, Utc::now() + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let old_data = QuestionData::from_question_type(&question_type_parent.question_type); + old_data.delete_from_db(id, transaction).await?; + + question_data.insert_into_db(id, transaction, snowflake_generator).await?; + + Ok(()) + } + + pub async fn delete(id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result<(), ChaosError> { + sqlx::query!("DELETE FROM questions WHERE id = $1 RETURNING id", id) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } +} + /// An enum that represents all the data types of question data that CHAOS can handle. /// This stores all the data for each question type. /// @@ -70,23 +244,95 @@ pub enum QuestionData { Ranking(MultiOptionData), } +/// An enum needed to track QuestionType in the database, +/// as DB enum does not contain the inner data. +#[derive(Deserialize, Serialize, PartialEq, sqlx::Type)] +#[sqlx(type_name = "question_type", rename_all = "PascalCase")] +pub enum QuestionType { + ShortAnswer, + MultiChoice, + MultiSelect, + DropDown, + Ranking, +} + +#[derive(Deserialize)] +pub struct QuestionTypeParent { + question_type: QuestionType +} + +impl QuestionType { + fn from_question_data(question_data: &QuestionData) -> Self { + match question_data { + QuestionData::ShortAnswer => QuestionType::ShortAnswer, + QuestionData::MultiChoice(_) => QuestionType::MultiChoice, + QuestionData::MultiSelect(_) => QuestionType::MultiSelect, + QuestionData::DropDown(_) => QuestionType::DropDown, + QuestionData::Ranking(_) => QuestionType::Ranking, + } + } +} + #[derive(Deserialize, Serialize)] pub struct MultiOptionData { options: Vec, } +impl Default for MultiOptionData { + fn default() -> Self { + Self { + // Return an empty vector to be replaced by real data later on. + options: vec![], + } + } +} + /// Each of these structs represent a row in the `multi_option_question_options` /// table. For a `MultiChoice` question like "What is your favourite programming /// language?", there would be rows for "Rust", "Java" and "TypeScript". #[derive(Deserialize, Serialize)] pub struct MultiOptionQuestionOption { - id: i32, + id: i64, display_order: i32, text: String, } impl QuestionData { - pub async fn validate(self) -> Result<(), ChaosError> { + fn from_question_type(question_type: &QuestionType) -> Self { + match question_type { + QuestionType::ShortAnswer => QuestionData::ShortAnswer, + QuestionType::MultiChoice => QuestionData::MultiChoice(MultiOptionData::default()), + QuestionType::MultiSelect => QuestionData::MultiSelect(MultiOptionData::default()), + QuestionType::DropDown => QuestionData::DropDown(MultiOptionData::default()), + QuestionType::Ranking => QuestionData::Ranking(MultiOptionData::default()), + } + } + + fn from_question_raw_data(question_type: QuestionType, multi_option_data: Option>>) -> Self { + return if question_type == QuestionType::ShortAnswer { + QuestionData::ShortAnswer + } else if + question_type == QuestionType::MultiChoice || + question_type == QuestionType::MultiSelect || + question_type == QuestionType::DropDown || + question_type == QuestionType::Ranking + { + let options = multi_option_data.expect("Data should exist for MultiOptionData variants").0; + let data = MultiOptionData { options }; + + match question_type { + QuestionType::MultiChoice => QuestionData::MultiChoice(data), + QuestionType::MultiSelect => QuestionData::MultiSelect(data), + QuestionType::DropDown => QuestionData::DropDown(data), + QuestionType::Ranking => QuestionData::Ranking(data), + _ => QuestionData::ShortAnswer // Should never be reached, hence return ShortAnswer + } + } else { + QuestionData::ShortAnswer // Should never be reached, hence return ShortAnswer + } + } + + pub fn validate(&self) -> Result<(), ChaosError> { match self { Self::ShortAnswer => Ok(()), Self::MultiChoice(data) @@ -102,7 +348,7 @@ impl QuestionData { } } - pub async fn insert_into_db(self, question_id: i64, pool: &Pool, mut snowflake_generator: SnowflakeIdGenerator) -> Result<(), ChaosError> { + pub async fn insert_into_db(self, question_id: i64, transaction: &mut Transaction<'_,Postgres>, mut snowflake_generator: SnowflakeIdGenerator) -> Result<(), ChaosError> { match self { Self::ShortAnswer => Ok(()), Self::MultiChoice(data) @@ -118,7 +364,101 @@ impl QuestionData { }); let query = query_builder.build(); - let result = query.execute(pool).await?; + query.execute(transaction.deref_mut()).await?; + + Ok(()) + } + } + } + + pub async fn get_from_db(self, question_id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result { + match self { + Self::ShortAnswer => Ok(Self::ShortAnswer), + Self::MultiChoice(_) => { + let data_vec = sqlx::query_as!( + MultiOptionQuestionOption, + " + SELECT id, display_order, text FROM multi_option_question_options + WHERE question_id = $1 + ", + question_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let data = MultiOptionData { + options: data_vec + }; + + Ok(Self::MultiChoice(data)) + }, + Self::MultiSelect(_) => { + let data_vec = sqlx::query_as!( + MultiOptionQuestionOption, + " + SELECT id, display_order, text FROM multi_option_question_options + WHERE question_id = $1 + ", + question_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let data = MultiOptionData { + options: data_vec + }; + + Ok(Self::MultiSelect(data)) + } + Self::DropDown(_) => { + let data_vec = sqlx::query_as!( + MultiOptionQuestionOption, + " + SELECT id, display_order, text FROM multi_option_question_options + WHERE question_id = $1 + ", + question_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let data = MultiOptionData { + options: data_vec + }; + + Ok(Self::DropDown(data)) + } + Self::Ranking(_) => { + let data_vec = sqlx::query_as!( + MultiOptionQuestionOption, + " + SELECT id, display_order, text FROM multi_option_question_options + WHERE question_id = $1 + ", + question_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + let data = MultiOptionData { + options: data_vec + }; + + Ok(Self::Ranking(data)) + } + } + } + + pub async fn delete_from_db(self, question_id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer => Ok(()), + Self::MultiChoice(_) + | Self::MultiSelect(_) + | Self::DropDown(_) + | Self::Ranking(_) => { + sqlx::query!("DELETE FROM multi_option_question_options WHERE question_id = $1", question_id) + .execute(transaction.deref_mut()) + .await?; Ok(()) }