diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..e68b4b48 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Database Configuration +DATABASE_URL=postgresql://username:password@localhost:5432/mission_control + +# Optional: Override default server settings +# SERVER_HOST=0.0.0.0 +# SERVER_PORT=3001 diff --git a/.gitignore b/.gitignore index e6376b27..041eadc2 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ coverage/ # Storybook build outputs .out .storybook-out + +.env \ No newline at end of file diff --git a/backend/Cargo.toml b/backend/Cargo.toml index cc82dd07..6a737247 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -13,3 +13,7 @@ serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] } +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.0", features = ["v4", "serde"] } +dotenvy = "0.15" diff --git a/backend/migrations/001_initial.sql b/backend/migrations/001_initial.sql new file mode 100644 index 00000000..88e32d99 --- /dev/null +++ b/backend/migrations/001_initial.sql @@ -0,0 +1,58 @@ +-- Create task_status enum +CREATE TYPE task_status AS ENUM ('todo', 'inprogress', 'done', 'cancelled'); + +-- Create users table +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create projects table +CREATE TABLE projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create tasks table +CREATE TABLE tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + status task_status NOT NULL DEFAULT 'todo', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes for better performance +CREATE INDEX idx_projects_owner_id ON projects(owner_id); +CREATE INDEX idx_tasks_project_id ON tasks(project_id); +CREATE INDEX idx_tasks_status ON tasks(status); + +-- Create updated_at trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create triggers to auto-update updated_at +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_projects_updated_at + BEFORE UPDATE ON projects + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_tasks_updated_at + BEFORE UPDATE ON tasks + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/src/main.rs b/backend/src/main.rs index f182f541..b8652574 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -3,11 +3,13 @@ use axum::{ Router, Json, response::Json as ResponseJson, - extract::Query, + extract::{Query, Extension}, }; use tower_http::cors::CorsLayer; use serde::{Deserialize, Serialize}; use tracing_subscriber; +use sqlx::{PgPool, postgres::PgPoolOptions}; +use std::env; mod routes; @@ -43,13 +45,26 @@ async fn echo_handler(Json(payload): Json) -> ResponseJson anyhow::Result<()> { + // Load environment variables from .env file + dotenvy::dotenv().ok(); + tracing_subscriber::fmt::init(); + // Database connection + let database_url = env::var("DATABASE_URL") + .expect("DATABASE_URL must be set in environment or .env file"); + + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(&database_url) + .await?; + let app = Router::new() .route("/", get(|| async { "Bloop API" })) .route("/health", get(health::health_check)) .route("/hello", get(hello_handler)) .route("/echo", post(echo_handler)) + .layer(Extension(pool)) .layer(CorsLayer::permissive()); let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await?; diff --git a/backend/src/models/api_response.rs b/backend/src/models/api_response.rs new file mode 100644 index 00000000..355efa17 --- /dev/null +++ b/backend/src/models/api_response.rs @@ -0,0 +1,8 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub message: Option, +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 355efa17..99e8a8e4 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,8 +1,9 @@ -use serde::Serialize; +pub mod user; +pub mod project; +pub mod task; +pub mod api_response; -#[derive(Debug, Serialize)] -pub struct ApiResponse { - pub success: bool, - pub data: Option, - pub message: Option, -} +pub use user::User; +pub use project::Project; +pub use task::{Task, TaskStatus}; +pub use api_response::ApiResponse; diff --git a/backend/src/models/project.rs b/backend/src/models/project.rs new file mode 100644 index 00000000..e62dc942 --- /dev/null +++ b/backend/src/models/project.rs @@ -0,0 +1,24 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct Project { + pub id: Uuid, + pub name: String, + pub owner_id: Uuid, // Foreign key to User + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateProject { + pub name: String, + pub owner_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateProject { + pub name: Option, +} diff --git a/backend/src/models/task.rs b/backend/src/models/task.rs new file mode 100644 index 00000000..ad9ad3ef --- /dev/null +++ b/backend/src/models/task.rs @@ -0,0 +1,38 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Type}; +use uuid::Uuid; + +#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq)] +#[sqlx(type_name = "task_status", rename_all = "lowercase")] +pub enum TaskStatus { + Todo, + InProgress, + Done, + Cancelled, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct Task { + pub id: Uuid, + pub project_id: Uuid, // Foreign key to Project + pub title: String, + pub description: Option, + pub status: TaskStatus, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateTask { + pub project_id: Uuid, + pub title: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateTask { + pub title: Option, + pub description: Option, + pub status: Option, +} diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs new file mode 100644 index 00000000..95086ab1 --- /dev/null +++ b/backend/src/models/user.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct User { + pub id: Uuid, + pub email: String, + pub password: String, // This should be hashed + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateUser { + pub email: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateUser { + pub email: Option, + pub password: Option, +}