diff --git a/migrations/2024-08-19-105140_create_tasks/down.sql b/migrations/2024-08-19-105140_create_tasks/down.sql new file mode 100644 index 0000000..90c7744 --- /dev/null +++ b/migrations/2024-08-19-105140_create_tasks/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` + +DROP TABLE IF EXISTS "tasks"; diff --git a/migrations/2024-08-19-105140_create_tasks/up.sql b/migrations/2024-08-19-105140_create_tasks/up.sql new file mode 100644 index 0000000..d9e7b2c --- /dev/null +++ b/migrations/2024-08-19-105140_create_tasks/up.sql @@ -0,0 +1,11 @@ +-- Your SQL goes here + +CREATE TABLE "tasks"( + "id" SERIAL NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "deadline" DATE, + "category" JSONB NOT NULL, + "project_id" INT4, + FOREIGN KEY ("project_id") REFERENCES "projects"("id") +); + diff --git a/src/errors/mod.rs b/src/errors/mod.rs index 625611e..a0ca9ed 100644 --- a/src/errors/mod.rs +++ b/src/errors/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod error; pub(crate) mod error_vec; pub(crate) mod project_create_error; +pub(crate) mod task_create_error; diff --git a/src/errors/task_create_error.rs b/src/errors/task_create_error.rs new file mode 100644 index 0000000..8c6301d --- /dev/null +++ b/src/errors/task_create_error.rs @@ -0,0 +1,52 @@ +use crate::errors::error::Error; +use crate::errors::error_vec::ErrorVec; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::str::FromStr; +use validator::{ValidationErrors, ValidationErrorsKind}; + +#[derive(Serialize, Deserialize, Debug)] +pub enum TaskCreateError { + TitleLengthInvalid, + ProjectNotFound, + Error(Error), +} + +impl From for ErrorVec { + fn from(e: ValidationErrors) -> Self { + e.errors() + .iter() + .flat_map(|(&field, error_kind)| match field { + "title" => match error_kind { + ValidationErrorsKind::Field(validation_errors) => validation_errors + .iter() + .map(|validation_error| validation_error.code.as_ref()) + .map(|code| match code { + "title_length" => TaskCreateError::TitleLengthInvalid, + _ => panic!("Unexpected validation error code: `{code}`."), + }) + .collect::>(), + _ => panic!("Unexpected validation error kind."), + }, + _ => panic!("Unexpected validation field name: `{field}`."), + }) + .collect::>() + .into() + } +} + +// has to be implemented for Dioxus server functions +impl Display for TaskCreateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +// has to be implemented for Dioxus server functions +impl FromStr for TaskCreateError { + type Err = (); + + fn from_str(_: &str) -> Result { + Ok(TaskCreateError::TitleLengthInvalid) + } +} diff --git a/src/models/category.rs b/src/models/category.rs new file mode 100644 index 0000000..c6ca42c --- /dev/null +++ b/src/models/category.rs @@ -0,0 +1,65 @@ +use chrono::{Duration, NaiveDate, NaiveTime}; +use diesel::deserialize::FromSql; +use diesel::pg::{Pg, PgValue}; +use diesel::serialize::{Output, ToSql}; +use diesel::sql_types::Jsonb; +use diesel::{AsExpression, FromSqlRow}; +use serde::{Deserialize, Serialize}; +use serde_with::DurationSeconds; +use std::io::Write; + +#[serde_with::serde_as] +#[derive(AsExpression, FromSqlRow, Serialize, Deserialize, Clone, Debug)] +#[diesel(sql_type = Jsonb)] +pub enum Category { + Inbox, + SomedayMaybe, + WaitingFor(String), + NextSteps, + Calendar { + date: NaiveDate, + #[serde_as(as = "Option>")] + reoccurance_interval: Option, + time: Option, + }, + LongTerm, + Done, + Trash, +} + +#[serde_with::serde_as] +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CalendarTime { + time: NaiveTime, + #[serde_as(as = "Option>")] + reminder_offset: Option, +} + +impl CalendarTime { + pub fn new(time: NaiveTime, reminder_offset: Option) -> Self { + Self { time, reminder_offset } + } +} + +impl ToSql for Category { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> diesel::serialize::Result { + let json = serde_json::to_string(self)?; + + // Prepend the JSONB version byte. + out.write_all(&[1])?; + out.write_all(json.as_bytes())?; + + Ok(diesel::serialize::IsNull::No) + } +} + +impl FromSql for Category { + fn from_sql(bytes: PgValue) -> diesel::deserialize::Result { + let bytes = bytes.as_bytes(); + if bytes.is_empty() { + return Err("Unexpected empty bytes (missing the JSONB version number).".into()); + } + let str = std::str::from_utf8(&bytes[1..])?; + serde_json::from_str(str).map_err(Into::into) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index dfc721a..9f449ce 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1 +1,3 @@ pub(crate) mod project; +pub(crate) mod category; +pub(crate) mod task; diff --git a/src/models/task.rs b/src/models/task.rs new file mode 100644 index 0000000..d67256a --- /dev/null +++ b/src/models/task.rs @@ -0,0 +1,60 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use validator::Validate; +use crate::models::category::Category; +use crate::schema::tasks; + +const TITLE_LENGTH_MIN: u64 = 1; +const TITLE_LENGTH_MAX: u64 = 255; + +#[derive(Queryable, Selectable, Serialize, Deserialize, Clone, Debug)] +#[diesel(table_name = crate::schema::tasks)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Task { + id: i32, + title: String, + deadline: Option, + category: Category, + project_id: Option, +} + +impl Task { + pub fn id(&self) -> i32 { + self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn deadline(&self) -> Option { + self.deadline + } + + pub fn category(&self) -> &Category { + &self.category + } + + pub fn project_id(&self) -> Option { + self.project_id + } +} + +#[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)] +#[diesel(table_name = tasks)] +pub struct NewTask { + #[validate(length(min = "TITLE_LENGTH_MIN", max = "TITLE_LENGTH_MAX", code = "title_length"))] + pub title: String, + pub deadline: Option, + pub category: Category, + pub project_id: Option, +} + +impl NewTask { + pub fn new( + title: String, deadline: Option, + category: Category, project_id: Option, + ) -> Self { + Self { title, deadline, category, project_id } + } +} diff --git a/src/schema/mod.rs b/src/schema/mod.rs index 6ad8716..a87d07d 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -6,3 +6,20 @@ diesel::table! { title -> Text, } } + +diesel::table! { + tasks (id) { + id -> Int4, + title -> Text, + deadline -> Nullable, + category -> Jsonb, + project_id -> Nullable, + } +} + +diesel::joinable!(tasks -> projects (project_id)); + +diesel::allow_tables_to_appear_in_same_query!( + projects, + tasks, +);