Create a plan for implementing projects and also create the backend API. (#19)
Creates projects to organize tasks under. (Note the migration also contains the tables for creating folders for projects as well because adding foreign keys is a PITA in sqlite apparently). Reviewed-on: #19 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
parent
69f4a6f1ca
commit
4552c347c6
17 changed files with 957 additions and 12 deletions
|
|
@ -32,6 +32,7 @@ async fn main() {
|
|||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.nest("/api/tasks", services::create_task_router())
|
||||
.nest("/api/projects", services::create_project_router())
|
||||
.layer(cors)
|
||||
.with_state(pool);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
mod project;
|
||||
mod task;
|
||||
|
||||
pub use project::*;
|
||||
pub use task::*;
|
||||
|
|
|
|||
122
backend/src/models/project.rs
Normal file
122
backend/src/models/project.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Pool, Row, Sqlite};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::services::AppError;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
pub enum ProjectStatus {
|
||||
Active,
|
||||
Done,
|
||||
Backlog,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Serialize)]
|
||||
pub struct ProjectModel {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub color: String,
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub sort_order: i64,
|
||||
pub status: ProjectStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl ProjectModel {
|
||||
async fn get_new_sort_order(pool: &Pool<Sqlite>) -> anyhow::Result<i64> {
|
||||
let result = sqlx::query!("SELECT MAX(sort_order) as max_order FROM projects;")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
match result.max_order {
|
||||
Some(i) => Ok(i + 1000),
|
||||
None => Ok(0),
|
||||
}
|
||||
}
|
||||
pub async fn insert(
|
||||
pool: &Pool<Sqlite>,
|
||||
title: String,
|
||||
color: String,
|
||||
) -> anyhow::Result<ProjectModel, AppError> {
|
||||
if title.is_empty() {
|
||||
return Err(AppError::Unprocessable(
|
||||
"Title must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if color.len() != 7 || color.chars().next() != Some('#') {
|
||||
return Err(AppError::Unprocessable(
|
||||
"Color is not a valid hex".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let sort_order = ProjectModel::get_new_sort_order(pool).await?;
|
||||
|
||||
let result = sqlx::query_as(
|
||||
"INSERT INTO projects(id, title, color, sort_order) VALUES($1, $2, $3, $4) RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(title)
|
||||
.bind(color)
|
||||
.bind(sort_order)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_by_id(
|
||||
pool: &Pool<Sqlite>,
|
||||
id: Uuid,
|
||||
) -> anyhow::Result<ProjectModel, AppError> {
|
||||
sqlx::query_as("SELECT * FROM projects WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => AppError::NotFound,
|
||||
e => e.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list(pool: &Pool<Sqlite>) -> anyhow::Result<Vec<ProjectModel>> {
|
||||
sqlx::query_as("SELECT * FROM projects;")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub async fn update(self, pool: &Pool<Sqlite>) -> anyhow::Result<ProjectModel, AppError> {
|
||||
let now: DateTime<Utc> = Utc::now();
|
||||
|
||||
let _ = sqlx::query!(
|
||||
"UPDATE projects
|
||||
SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7
|
||||
WHERE id=$8",
|
||||
self.title,
|
||||
self.color,
|
||||
self.folder_id,
|
||||
self.status,
|
||||
self.sort_order,
|
||||
now,
|
||||
self.completed_at,
|
||||
self.id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
ProjectModel::get_by_id(pool, self.id).await
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &Pool<Sqlite>, id: Uuid) -> anyhow::Result<()> {
|
||||
sqlx::query!("DELETE FROM projects WHERE id = $1", id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod projects;
|
||||
mod tasks;
|
||||
|
||||
use axum::{
|
||||
|
|
@ -26,11 +27,14 @@ pub struct ErrorJson {
|
|||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
Self::InternalError(anyhow_error) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong {}", anyhow_error),
|
||||
)
|
||||
.into_response(),
|
||||
Self::InternalError(anyhow_error) => {
|
||||
error!(err = ?anyhow_error, "Internal Server Error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong {}", anyhow_error),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Self::JsonExtractError(rej) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(ErrorJson {
|
||||
|
|
@ -85,5 +89,7 @@ impl From<sqlx::Error> for AppError {
|
|||
}
|
||||
}
|
||||
|
||||
pub use projects::*;
|
||||
use serde::Serialize;
|
||||
pub use tasks::*;
|
||||
use tracing::error;
|
||||
|
|
|
|||
106
backend/src/services/projects.rs
Normal file
106
backend/src/services/projects.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
};
|
||||
use axum_extra::extract::WithRejection;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{ProjectModel, ProjectStatus};
|
||||
|
||||
use super::AppError;
|
||||
|
||||
pub fn create_project_router() -> Router<Pool<Sqlite>> {
|
||||
Router::new()
|
||||
.route("/", post(create_project).get(list_projects))
|
||||
.route(
|
||||
"/{project_id}",
|
||||
get(get_project).put(update_project).delete(delete_project),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateProjectRequest {
|
||||
title: String,
|
||||
color: String,
|
||||
}
|
||||
|
||||
pub async fn create_project(
|
||||
State(pool): State<Pool<Sqlite>>,
|
||||
WithRejection(Json(input), _): WithRejection<Json<CreateProjectRequest>, AppError>,
|
||||
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ProjectModel::insert(&pool, input.title, input.color).await?),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_projects(
|
||||
State(pool): State<Pool<Sqlite>>,
|
||||
) -> Result<(StatusCode, Json<Vec<ProjectModel>>), AppError> {
|
||||
Ok((StatusCode::OK, Json(ProjectModel::list(&pool).await?)))
|
||||
}
|
||||
|
||||
pub async fn get_project(
|
||||
State(pool): State<Pool<Sqlite>>,
|
||||
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
|
||||
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ProjectModel::get_by_id(&pool, project_id).await?),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateProjectRequest {
|
||||
title: Option<String>,
|
||||
color: Option<String>,
|
||||
status: Option<ProjectStatus>,
|
||||
}
|
||||
|
||||
pub async fn update_project(
|
||||
State(pool): State<Pool<Sqlite>>,
|
||||
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
|
||||
WithRejection(Json(input), _): WithRejection<Json<UpdateProjectRequest>, AppError>,
|
||||
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
|
||||
let mut project = ProjectModel::get_by_id(&pool, project_id).await?;
|
||||
|
||||
if let Some(new_title) = input.title {
|
||||
if new_title.len() == 0 {
|
||||
return Err(AppError::Unprocessable(
|
||||
"Title must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
project.title = new_title;
|
||||
}
|
||||
|
||||
if let Some(color) = input.color {
|
||||
if color.len() != 7 || color.chars().next() != Some('#') {
|
||||
return Err(AppError::Unprocessable(
|
||||
"Color is not a valid hex".to_string(),
|
||||
));
|
||||
}
|
||||
project.color = color;
|
||||
}
|
||||
|
||||
if let Some(status) = input.status {
|
||||
project.status = status;
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(project.update(&pool).await?)))
|
||||
}
|
||||
|
||||
pub async fn delete_project(
|
||||
State(pool): State<Pool<Sqlite>>,
|
||||
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
ProjectModel::get_by_id(&pool, project_id).await?;
|
||||
ProjectModel::delete(&pool, project_id).await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue