Create a plan for implementing projects and also create the backend API. (#19)
Some checks failed
Check / Backend (push) Successful in 6m55s
Check / Frontend (push) Successful in 2m2s
Docker Build / Build and Push Backend Image (push) Failing after 11m21s
Docker Build / Build and Push Frontend Image (push) Successful in 5m44s

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:
Drew 2025-10-28 04:13:12 +00:00 committed by Drew
parent 69f4a6f1ca
commit 4552c347c6
17 changed files with 957 additions and 12 deletions

View file

@ -1,3 +1,5 @@
mod project;
mod task;
pub use project::*;
pub use task::*;

View 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(())
}
}