Compare commits
No commits in common. "e188fec99cd56e9ba71248ebccf1865e20267a99" and "c2b7c12905faf0321fe2c801d13abc7a8f6ba7ff" have entirely different histories.
e188fec99c
...
c2b7c12905
13 changed files with 15 additions and 1923 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +0,0 @@
|
||||||
.claude/settings.local.json
|
|
||||||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
|
|
@ -1,3 +1 @@
|
||||||
/target
|
/target
|
||||||
|
|
||||||
*.db
|
|
||||||
|
|
|
||||||
1577
backend/Cargo.lock
generated
1577
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,13 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.99"
|
|
||||||
axum = "0.8.4"
|
axum = "0.8.4"
|
||||||
chrono = "0.4.41"
|
|
||||||
serde = "1.0.219"
|
|
||||||
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] }
|
|
||||||
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] }
|
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] }
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
uuid = { version = "1.18.0", features = ["v4"] }
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- Add down migration script here
|
|
||||||
DROP TABLE tasks
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
CREATE TABLE tasks (
|
|
||||||
id UUID PRIMARY KEY NOT NULL,
|
|
||||||
title VARCHAR NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
status TEXT CHECK(status IN ('todo', 'done', 'backlog')) DEFAULT 'todo',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
use anyhow::Result;
|
|
||||||
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
/// Database configuration
|
|
||||||
pub struct DatabaseConfig {
|
|
||||||
pub database_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DatabaseConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
database_url: "sqlite:local.db".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a SQLx connection pool
|
|
||||||
pub async fn create_pool(config: &DatabaseConfig) -> Result<SqlitePool> {
|
|
||||||
let options = SqliteConnectOptions::from_str(&config.database_url)?.create_if_missing(true);
|
|
||||||
|
|
||||||
let pool = SqlitePool::connect_with(options).await?;
|
|
||||||
|
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
|
||||||
|
|
||||||
Ok(pool)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize database with connection pool
|
|
||||||
pub async fn initialize_database() -> Result<SqlitePool> {
|
|
||||||
let config = DatabaseConfig::default();
|
|
||||||
create_pool(&config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub async fn create_test_pool() -> Result<SqlitePool> {
|
|
||||||
let options = SqliteConnectOptions::from_str("sqlite::memory:")?.create_if_missing(true);
|
|
||||||
|
|
||||||
let pool = SqlitePool::connect_with(options).await?;
|
|
||||||
|
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
|
||||||
|
|
||||||
Ok(pool)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
pub mod connection;
|
|
||||||
|
|
||||||
pub use connection::*;
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
use axum::{Router, routing::get};
|
use axum::{Router, routing::get};
|
||||||
|
|
||||||
mod models;
|
|
||||||
mod database;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
mod task;
|
|
||||||
|
|
||||||
pub use task::*;
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
use anyhow::{Result, bail};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[sqlx(rename_all = "lowercase")]
|
|
||||||
pub enum TaskStatus {
|
|
||||||
Todo,
|
|
||||||
Done,
|
|
||||||
Backlog,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
pub struct TaskModel {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub title: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub status: TaskStatus,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
pub completed_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TaskModel {
|
|
||||||
pub async fn insert(
|
|
||||||
pool: &SqlitePool,
|
|
||||||
title: &str,
|
|
||||||
description: Option<&str>,
|
|
||||||
) -> Result<TaskModel> {
|
|
||||||
if title.len() == 0 {
|
|
||||||
bail!("Title must not be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let result = sqlx::query_as(
|
|
||||||
"INSERT INTO tasks (id, title, description) VALUES ($1, $2, $3) RETURNING *",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(title)
|
|
||||||
.bind(description)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_by_id(pool: &SqlitePool, id: Uuid) -> Result<TaskModel> {
|
|
||||||
let result = sqlx::query_as("SELECT * FROM tasks WHERE id = $1")
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update(self, pool: &SqlitePool) -> Result<TaskModel> {
|
|
||||||
let now: DateTime<Utc> = Utc::now();
|
|
||||||
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE tasks
|
|
||||||
SET title=$1, description=$2, status=$3, updated_at=$4, completed_at=$5
|
|
||||||
WHERE id=$6",
|
|
||||||
)
|
|
||||||
.bind(&self.title)
|
|
||||||
.bind(&self.description)
|
|
||||||
.bind(&self.status)
|
|
||||||
.bind(now)
|
|
||||||
.bind(&self.completed_at)
|
|
||||||
.bind(&self.id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
TaskModel::get_by_id(pool, self.id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
|
||||||
sqlx::query("DELETE FROM tasks WHERE id = $1")
|
|
||||||
.bind(id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<TaskModel>> {
|
|
||||||
let tasks = sqlx::query_as("SELECT * FROM tasks ORDER BY created_at DESC")
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(tasks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::database::create_test_pool;
|
|
||||||
|
|
||||||
async fn setup_test_db() -> SqlitePool {
|
|
||||||
create_test_pool()
|
|
||||||
.await
|
|
||||||
.expect("Failed to create test pool")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_insert_task() {
|
|
||||||
let pool = setup_test_db().await;
|
|
||||||
|
|
||||||
let title = "Test Task";
|
|
||||||
let description = "Test Description";
|
|
||||||
|
|
||||||
let task = TaskModel::insert(&pool, title, Some(description))
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert task");
|
|
||||||
|
|
||||||
assert_eq!(task.title, title);
|
|
||||||
assert_eq!(task.description, Some(description.to_string()));
|
|
||||||
assert_eq!(task.status, TaskStatus::Todo);
|
|
||||||
assert!(task.completed_at.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_insert_task_with_null_description() {
|
|
||||||
let pool = setup_test_db().await;
|
|
||||||
|
|
||||||
let title = "Test Task";
|
|
||||||
let description = None;
|
|
||||||
|
|
||||||
let task = TaskModel::insert(&pool, title, description)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert task");
|
|
||||||
|
|
||||||
assert_eq!(task.title, title);
|
|
||||||
assert_eq!(task.description, None);
|
|
||||||
assert_eq!(task.status, TaskStatus::Todo);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_by_id_found() {
|
|
||||||
let pool = setup_test_db().await;
|
|
||||||
|
|
||||||
let title = "Test Task";
|
|
||||||
let description = Some("Test Description");
|
|
||||||
|
|
||||||
let inserted_task = TaskModel::insert(&pool, title, description)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert task");
|
|
||||||
|
|
||||||
let retrieved_task = TaskModel::get_by_id(&pool, inserted_task.id)
|
|
||||||
.await
|
|
||||||
.expect("Failed to get task by id");
|
|
||||||
|
|
||||||
assert_eq!(retrieved_task.id, inserted_task.id);
|
|
||||||
assert_eq!(retrieved_task.title, inserted_task.title);
|
|
||||||
assert_eq!(retrieved_task.description, inserted_task.description);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_by_id_not_found() {
|
|
||||||
let pool = setup_test_db().await;
|
|
||||||
|
|
||||||
let non_existent_id = Uuid::new_v4();
|
|
||||||
let result = TaskModel::get_by_id(&pool, non_existent_id).await;
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_update_task() {
|
|
||||||
let pool = setup_test_db().await;
|
|
||||||
|
|
||||||
let title = "Original Task";
|
|
||||||
let description = Some("Original Description");
|
|
||||||
|
|
||||||
let mut task = TaskModel::insert(&pool, title, description)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert task");
|
|
||||||
|
|
||||||
// Update the task
|
|
||||||
task.title = "Updated Task".to_string();
|
|
||||||
task.description = Some("Updated Description".to_string());
|
|
||||||
task.status = TaskStatus::Done;
|
|
||||||
task.completed_at = Some(Utc::now());
|
|
||||||
|
|
||||||
let updated_task = task.update(&pool).await.expect("Failed to update task");
|
|
||||||
|
|
||||||
assert_eq!(updated_task.title, "Updated Task");
|
|
||||||
assert_eq!(
|
|
||||||
updated_task.description,
|
|
||||||
Some("Updated Description".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(updated_task.status, TaskStatus::Done);
|
|
||||||
assert!(updated_task.completed_at.is_some());
|
|
||||||
assert!(updated_task.updated_at > updated_task.created_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_task() {
|
|
||||||
let pool = setup_test_db().await;
|
|
||||||
|
|
||||||
let title = "Task to Delete";
|
|
||||||
let description = Some("This will be deleted");
|
|
||||||
|
|
||||||
let task = TaskModel::insert(&pool, title, description)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert task");
|
|
||||||
|
|
||||||
// Delete the task
|
|
||||||
TaskModel::delete(&pool, task.id)
|
|
||||||
.await
|
|
||||||
.expect("Failed to delete task");
|
|
||||||
|
|
||||||
// Verify it's gone
|
|
||||||
let result = TaskModel::get_by_id(&pool, task.id).await;
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_list_all_tasks() {
|
|
||||||
let pool = setup_test_db().await;
|
|
||||||
|
|
||||||
// Insert multiple tasks
|
|
||||||
let _task1 = TaskModel::insert(&pool, "Task 1", Some("Desc 1"))
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert task 1");
|
|
||||||
|
|
||||||
let _task2 = TaskModel::insert(&pool, "Task 2", None)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert task 2");
|
|
||||||
|
|
||||||
let tasks = TaskModel::list_all(&pool)
|
|
||||||
.await
|
|
||||||
.expect("Failed to list tasks");
|
|
||||||
|
|
||||||
assert_eq!(tasks.len(), 2);
|
|
||||||
|
|
||||||
// Check that both tasks are present (order may vary due to timing)
|
|
||||||
let titles: Vec<&String> = tasks.iter().map(|t| &t.title).collect();
|
|
||||||
assert!(titles.contains(&&"Task 1".to_string()));
|
|
||||||
assert!(titles.contains(&&"Task 2".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_task_status_serialization() {
|
|
||||||
let pool = setup_test_db().await;
|
|
||||||
|
|
||||||
// Test each status
|
|
||||||
let statuses = vec![TaskStatus::Todo, TaskStatus::Done, TaskStatus::Backlog];
|
|
||||||
|
|
||||||
for status in statuses {
|
|
||||||
let mut task = TaskModel::insert(&pool, "Test Task", None)
|
|
||||||
.await
|
|
||||||
.expect("Failed to insert task");
|
|
||||||
|
|
||||||
task.status = status;
|
|
||||||
task = task.update(&pool).await.expect("Failed to update task");
|
|
||||||
|
|
||||||
assert_eq!(task.status, status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_empty_title_constraint() {
|
|
||||||
let pool = setup_test_db().await;
|
|
||||||
|
|
||||||
let result = TaskModel::insert(&pool, "", None).await;
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
justfile
13
justfile
|
|
@ -1,8 +1,6 @@
|
||||||
# Temporary until we have a frontend.
|
# Temporary until we have a frontend.
|
||||||
set working-directory := 'backend'
|
set working-directory := 'backend'
|
||||||
|
|
||||||
export DATABASE_URL :="sqlite://local.db"
|
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
cargo run
|
cargo run
|
||||||
|
|
||||||
|
|
@ -22,14 +20,3 @@ clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
|
|
||||||
|
|
||||||
reset-db:
|
|
||||||
sqlx database drop
|
|
||||||
sqlx database create
|
|
||||||
sqlx migrate run
|
|
||||||
|
|
||||||
migrate:
|
|
||||||
sqlx migrate run
|
|
||||||
|
|
||||||
migrate-revert:
|
|
||||||
sqlx migrate revert
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ captains-log/
|
||||||
## Phase 2: Core Database Layer (Days 3-4)
|
## Phase 2: Core Database Layer (Days 3-4)
|
||||||
|
|
||||||
### Task 2.0: Create Initial Database Migration
|
### Task 2.0: Create Initial Database Migration
|
||||||
- [x] **File**: `backend/migrations/001_create_tasks.sql`
|
- [ ] **File**: `backend/migrations/001_create_tasks.sql`
|
||||||
- Create tasks table with all required fields
|
- Create tasks table with all required fields
|
||||||
- Add proper indexes and constraints
|
- Add proper indexes and constraints
|
||||||
- Include created_at, updated_at triggers if needed
|
- Include created_at, updated_at triggers if needed
|
||||||
|
|
@ -77,7 +77,7 @@ captains-log/
|
||||||
- **Expected outcome**: `sqlx migrate run` creates table successfully
|
- **Expected outcome**: `sqlx migrate run` creates table successfully
|
||||||
|
|
||||||
### Task 2.1: Define Task Model
|
### Task 2.1: Define Task Model
|
||||||
- [x] **File**: `backend/src/database/models.rs`
|
- [ ] **File**: `backend/src/database/models.rs`
|
||||||
- Create Task struct with SQLx derives
|
- Create Task struct with SQLx derives
|
||||||
- Add TaskStatus and Priority enums
|
- Add TaskStatus and Priority enums
|
||||||
- Implement proper serialization/deserialization
|
- Implement proper serialization/deserialization
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue