diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml index 0a335df..7859999 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -15,6 +15,7 @@ regex = "1.12.2" serde = { version = "1.0.228", features = ["derive", "serde_derive"] } serde_json = "1.0.145" sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] } +thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } tower = { version = "0.5.2", features = ["tokio", "tracing"] } tower-http = { version = "0.6.7", features = ["cors"] } diff --git a/src/backend/migrations/0002_create_users.sql b/src/backend/migrations/0002_create_users.sql new file mode 100644 index 0000000..8e4c10a --- /dev/null +++ b/src/backend/migrations/0002_create_users.sql @@ -0,0 +1,8 @@ +CREATE TABLE users ( + uuid UUID PRIMARY KEY, + username VARCHAR NOT NULL UNIQUE, + email VARCHAR UNIQUE, + password_hash VARCHAR NOT NULL, + first_name VARCHAR, + last_name VARCHAR +); diff --git a/src/backend/src/core/user_routines.rs b/src/backend/src/core/user_routines.rs index 4d98f09..9e302a1 100644 --- a/src/backend/src/core/user_routines.rs +++ b/src/backend/src/core/user_routines.rs @@ -1,7 +1,8 @@ -use crate::prelude::*; +use crate::{infra::db, prelude::*}; use std::sync::Arc; -use axum::http::StatusCode; +use anyhow::Result; +use axum::{Json, http::StatusCode}; use validator::Validate; use crate::{ @@ -10,14 +11,31 @@ use crate::{ }; pub async fn create(state: Arc, new_user: NewUser) -> Result { - if let Err(_) = new_user.validate() { - return Err(StatusCode::BAD_REQUEST); - } + new_user.validate().map_err(|e| { + error!("User validation failed: {e}"); + StatusCode::BAD_REQUEST + })?; let internal = InternalNewUser::try_from(new_user).map_err(|e| { error!("Conversion to InternalUser failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; - todo!("Hook up return once db setup ready"); + let created_user = db::user::create(&state.db_pool, internal) + .await + .map_err(|e| { + error!("Failed to create new user: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(User::from(created_user)) +} + +pub async fn get_all(state: Arc) -> Result, StatusCode> { + let users = db::user::get_safe_all(&state.db_pool).await.map_err(|e| { + error!("Failed to fetch all users: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(users) } diff --git a/src/backend/src/domain/user.rs b/src/backend/src/domain/user.rs index f5871f5..a228f3e 100644 --- a/src/backend/src/domain/user.rs +++ b/src/backend/src/domain/user.rs @@ -4,6 +4,7 @@ use axum::response::IntoResponse; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; +use sqlx::prelude::FromRow; use uuid::Uuid; use validator::Validate; @@ -30,25 +31,25 @@ pub struct NewUser { #[derive(Debug, Clone, Deserialize)] pub struct InternalNewUser { - uuid: Uuid, - username: String, - email: Option, - password_hash: String, - first_name: Option, - last_name: Option, + pub uuid: Uuid, + pub username: String, + pub email: Option, + pub password_hash: String, + pub first_name: Option, + pub last_name: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize, FromRow)] pub struct InternalUser { - uuid: Uuid, - username: String, - email: Option, - password_hash: String, - first_name: Option, - last_name: Option, + pub uuid: Uuid, + pub username: String, + pub email: Option, + pub password_hash: String, + pub first_name: Option, + pub last_name: Option, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct User { uuid: Uuid, username: String, diff --git a/src/backend/src/infra/db/user.rs b/src/backend/src/infra/db/user.rs index e69de29..2040e9e 100644 --- a/src/backend/src/infra/db/user.rs +++ b/src/backend/src/infra/db/user.rs @@ -0,0 +1,81 @@ +use crate::{ + domain::user::{InternalNewUser, InternalUser, User}, + prelude::*, +}; +use anyhow::{Ok, Result, anyhow}; +use sqlx::PgPool; +use uuid::Uuid; + +pub async fn create(pool: &PgPool, new_user: InternalNewUser) -> Result { + let user = sqlx::query_as::<_, InternalUser>( + r#" + INSERT INTO users (uuid, username, email, password_hash, first_name, last_name) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING uuid, username, email, password_hash, first_name, last_name + "#, + ) + .bind(new_user.uuid) + .bind(&new_user.username) + .bind(&new_user.email) + .bind(&new_user.password_hash) + .bind(&new_user.first_name) + .bind(&new_user.last_name) + .fetch_one(pool) + .await?; + + Ok(user) +} + +pub async fn get_by_uuid(pool: &PgPool, uuid: Uuid) -> Result> { + let user = sqlx::query_as::<_, InternalUser>( + r#" + SELECT uuid, username, email, password_hash, first_name, last_name FROM users WHERE uuid = $ + "#, + ) + .bind(uuid) + .fetch_optional(pool) + .await?; + + Ok(user) +} + +pub async fn get_safe_by_uuid(pool: &PgPool, uuid: Uuid) -> Result> { + let user = sqlx::query_as::<_, User>( + r#" + SELECT uuid, username, email, first_name, last_name FROM users WHERE uuid = $ + "#, + ) + .bind(uuid) + .fetch_optional(pool) + .await?; + + Ok(user) +} + +pub async fn get_all(pool: &PgPool) -> Result> { + let users = sqlx::query_as::<_, InternalUser>( + r#" + SELECT uuid, username, email, password_hash, first_name, last_name + FROM users + ORDER BY username ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(users) +} + +pub async fn get_safe_all(pool: &PgPool) -> Result> { + let users = sqlx::query_as::<_, User>( + r#" + SELECT uuid, username, email, first_name, last_name + FROM users + ORDER BY username ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(users) +} diff --git a/src/backend/src/router/mod.rs b/src/backend/src/router/mod.rs index 6ae9a94..dd18cf1 100644 --- a/src/backend/src/router/mod.rs +++ b/src/backend/src/router/mod.rs @@ -23,10 +23,13 @@ pub async fn init_router(app_state: Arc) -> Router { ), ) .route( - "/api/user/create", + "/api/users", post(user_routes::create) .layer(ServiceBuilder::new().layer(middleware::cors())) - .with_state(app_state), + .with_state(app_state.clone()) + .get(user_routes::get_all) + .layer(ServiceBuilder::new().layer(middleware::cors())) + .with_state(app_state.clone()), ) } diff --git a/src/backend/src/router/user_routes.rs b/src/backend/src/router/user_routes.rs index 66175ad..65a6b43 100644 --- a/src/backend/src/router/user_routes.rs +++ b/src/backend/src/router/user_routes.rs @@ -1,27 +1,22 @@ +use crate::prelude::*; use std::sync::Arc; -use axum::{Json, extract::State, http::StatusCode}; -use serde_json::json; -use tracing::{debug, error, info, warn}; -use validator::Validate; - use crate::{ + core, domain::user::{InternalNewUser, NewUser, User}, state::AppState, }; +use axum::{Json, extract::State, http::StatusCode}; pub async fn create( State(state): State>, Json(new_user): Json, ) -> Result, StatusCode> { - if let Err(_) = new_user.validate() { - return Err(StatusCode::BAD_REQUEST); - } - - let internal = InternalNewUser::try_from(new_user).map_err(|e| { - error!("Conversion to InternalUser failed: {e}"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - todo!("Hook up return once db setup ready"); + let user = core::user_routines::create(state, new_user).await?; + Ok(Json(user)) +} + +pub async fn get_all(State(state): State>) -> Result>, StatusCode> { + let users = core::user_routines::get_all(state).await?; + Ok(Json(users)) }