feat: initialize template shell and basic components
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts, OptionalFromRequestParts},
|
||||
http::request::Parts,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use crate::common::config::Config;
|
||||
use crate::common::errors::AppError;
|
||||
use crate::auth::models::Claims;
|
||||
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: ObjectId,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
Config: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let config = Config::from_ref(state);
|
||||
|
||||
let jar = CookieJar::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| AppError::Unauthorized("Failed to parse cookies".to_string()))?;
|
||||
|
||||
let token = jar
|
||||
.get("token")
|
||||
.map(|cookie| cookie.value().to_string())
|
||||
.ok_or_else(|| AppError::Unauthorized("No authorization token found. Please sign in.".to_string()))?;
|
||||
|
||||
let token_data = decode::<Claims>(
|
||||
&token,
|
||||
&DecodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|e| AppError::Unauthorized(format!("Invalid session: {}", e)))?;
|
||||
|
||||
let user_id = ObjectId::parse_str(&token_data.claims.sub)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user session identifier".to_string()))?;
|
||||
|
||||
Ok(AuthenticatedUser {
|
||||
user_id,
|
||||
username: token_data.claims.username,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> OptionalFromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
Config: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Option<Self>, Self::Rejection> {
|
||||
let config = Config::from_ref(state);
|
||||
|
||||
let jar = CookieJar::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| AppError::Unauthorized("Failed to parse cookies".to_string()))?;
|
||||
|
||||
let Some(token_cookie) = jar.get("token") else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let token = token_cookie.value();
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|e| AppError::Unauthorized(format!("Invalid session: {}", e)))?;
|
||||
|
||||
let user_id = ObjectId::parse_str(&token_data.claims.sub)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user session identifier".to_string()))?;
|
||||
|
||||
Ok(Some(AuthenticatedUser {
|
||||
user_id,
|
||||
username: token_data.claims.username,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Form, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::{
|
||||
cookie::{Cookie, SameSite},
|
||||
CookieJar,
|
||||
};
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use serde::Deserialize;
|
||||
use time::Duration;
|
||||
use crate::common::config::Config;
|
||||
use crate::common::errors::AppError;
|
||||
use crate::auth::models::Claims;
|
||||
use crate::auth::extractors::AuthenticatedUser;
|
||||
use crate::auth::repository::{UserRepository, MongoUserRepository};
|
||||
|
||||
// Wrapper for rendering Askama HTML
|
||||
struct HtmlTemplate<T>(T);
|
||||
|
||||
impl<T> IntoResponse for HtmlTemplate<T>
|
||||
where
|
||||
T: Template,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
match self.0.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to render template: {:?}", err);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Askama template structs
|
||||
#[derive(Template)]
|
||||
#[template(path = "auth/login.html")]
|
||||
struct LoginTemplate {
|
||||
error: Option<String>,
|
||||
authenticated: bool,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "auth/register.html")]
|
||||
struct RegisterTemplate {
|
||||
error: Option<String>,
|
||||
success: Option<String>,
|
||||
authenticated: bool,
|
||||
username: String,
|
||||
}
|
||||
|
||||
// HANDLERS
|
||||
|
||||
pub async fn get_login(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> impl IntoResponse {
|
||||
if user_opt.is_some() {
|
||||
return Redirect::to("/tasks").into_response();
|
||||
}
|
||||
HtmlTemplate(LoginTemplate {
|
||||
error: None,
|
||||
authenticated: false,
|
||||
username: "".to_string(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthPayload {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn post_login(
|
||||
State(config): State<Config>,
|
||||
State(user_repo): State<MongoUserRepository>,
|
||||
jar: CookieJar,
|
||||
Form(payload): Form<AuthPayload>,
|
||||
) -> Result<Response, AppError> {
|
||||
let username = payload.username.trim();
|
||||
|
||||
// Find user
|
||||
let user = user_repo.find_by_username(username).await?;
|
||||
let Some(user) = user else {
|
||||
return Ok(HtmlTemplate(LoginTemplate {
|
||||
error: Some("Invalid username or password".to_string()),
|
||||
authenticated: false,
|
||||
username: "".to_string(),
|
||||
})
|
||||
.into_response());
|
||||
};
|
||||
|
||||
// Verify password
|
||||
match verify(&payload.password, &user.password_hash) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
return Ok(HtmlTemplate(LoginTemplate {
|
||||
error: Some("Invalid username or password".to_string()),
|
||||
authenticated: false,
|
||||
username: "".to_string(),
|
||||
})
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
let exp = chrono::Utc::now()
|
||||
.checked_add_signed(chrono::Duration::hours(24))
|
||||
.expect("Valid duration")
|
||||
.timestamp() as usize;
|
||||
|
||||
let claims = Claims {
|
||||
sub: user.id.expect("User document must have ID").to_hex(),
|
||||
username: user.username,
|
||||
exp,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||
)?;
|
||||
|
||||
// Set cookie
|
||||
let cookie = Cookie::build(("token", token))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.max_age(Duration::days(1));
|
||||
|
||||
let updated_jar = jar.add(cookie);
|
||||
|
||||
Ok((updated_jar, Redirect::to("/tasks")).into_response())
|
||||
}
|
||||
|
||||
pub async fn get_register(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> impl IntoResponse {
|
||||
if user_opt.is_some() {
|
||||
return Redirect::to("/tasks").into_response();
|
||||
}
|
||||
HtmlTemplate(RegisterTemplate {
|
||||
error: None,
|
||||
success: None,
|
||||
authenticated: false,
|
||||
username: "".to_string(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn post_register(
|
||||
State(user_repo): State<MongoUserRepository>,
|
||||
Form(payload): Form<AuthPayload>,
|
||||
) -> Result<Response, AppError> {
|
||||
let username = payload.username.trim();
|
||||
let password = payload.password.trim();
|
||||
|
||||
if username.is_empty() || password.is_empty() {
|
||||
return Ok(HtmlTemplate(RegisterTemplate {
|
||||
error: Some("Username and password cannot be empty".to_string()),
|
||||
success: None,
|
||||
authenticated: false,
|
||||
username: "".to_string(),
|
||||
})
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
let existing_user = user_repo.find_by_username(username).await?;
|
||||
if existing_user.is_some() {
|
||||
return Ok(HtmlTemplate(RegisterTemplate {
|
||||
error: Some("Username already taken".to_string()),
|
||||
success: None,
|
||||
authenticated: false,
|
||||
username: "".to_string(),
|
||||
})
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Hash password
|
||||
let hashed_password = hash(password, DEFAULT_COST)?;
|
||||
|
||||
// Create user
|
||||
user_repo.create(username, &hashed_password).await?;
|
||||
|
||||
Ok(HtmlTemplate(RegisterTemplate {
|
||||
error: None,
|
||||
success: Some("Registration successful! You can now log in.".to_string()),
|
||||
authenticated: false,
|
||||
username: "".to_string(),
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn post_logout(jar: CookieJar) -> impl IntoResponse {
|
||||
let mut cookie = Cookie::new("token", "");
|
||||
cookie.set_path("/");
|
||||
cookie.set_max_age(Some(Duration::ZERO)); // Clear cookie
|
||||
|
||||
let updated_jar = jar.add(cookie);
|
||||
(updated_jar, Redirect::to("/")).into_response()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod repository;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use crate::common::config::Config;
|
||||
use crate::auth::repository::MongoUserRepository;
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
Config: axum::extract::FromRef<S>,
|
||||
MongoUserRepository: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/auth/login", get(handlers::get_login).post(handlers::post_login))
|
||||
.route("/auth/register", get(handlers::get_register).post(handlers::post_register))
|
||||
.route("/auth/logout", post(handlers::post_logout))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::serde_as;
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct User {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
#[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")]
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub username: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use mongodb::{
|
||||
bson::doc,
|
||||
Database,
|
||||
};
|
||||
use crate::common::errors::AppError;
|
||||
use crate::auth::models::User;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait UserRepository {
|
||||
async fn find_by_username(&self, username: &str) -> Result<Option<User>, AppError>;
|
||||
async fn create(&self, username: &str, password_hash: &str) -> Result<User, AppError>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MongoUserRepository {
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl MongoUserRepository {
|
||||
pub fn new(db: Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl UserRepository for MongoUserRepository {
|
||||
async fn find_by_username(&self, username: &str) -> Result<Option<User>, AppError> {
|
||||
let collection = self.db.collection::<User>("users");
|
||||
let filter = doc! { "username": username };
|
||||
|
||||
let user = collection.find_one(filter).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn create(&self, username: &str, password_hash: &str) -> Result<User, AppError> {
|
||||
let collection = self.db.collection::<User>("users");
|
||||
|
||||
let new_user = User {
|
||||
id: None,
|
||||
username: username.to_string(),
|
||||
password_hash: password_hash.to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let insert_result = collection.insert_one(new_user.clone()).await?;
|
||||
|
||||
let mut user = new_user;
|
||||
user.id = Some(insert_result.inserted_id.as_object_id().expect("Inserted ID is ObjectId"));
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub database_name: String,
|
||||
pub jwt_secret: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
// Load .env if present
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "mongodb://localhost:27017".to_string());
|
||||
|
||||
let database_name = env::var("DATABASE_NAME")
|
||||
.unwrap_or_else(|_| "stick_db".to_string());
|
||||
|
||||
let jwt_secret = env::var("JWT_SECRET")
|
||||
.unwrap_or_else(|_| "super_secret_fallback_key_for_json_web_token_signing_and_verification_must_be_long_enough".to_string());
|
||||
|
||||
let host = env::var("HOST")
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
|
||||
let port = env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3000);
|
||||
|
||||
Self {
|
||||
database_url,
|
||||
database_name,
|
||||
jwt_secret,
|
||||
host,
|
||||
port,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use mongodb::{
|
||||
bson::doc,
|
||||
options::{ClientOptions, IndexOptions},
|
||||
Client, Database, IndexModel,
|
||||
};
|
||||
use tracing::info;
|
||||
use crate::common::config::Config;
|
||||
use crate::common::errors::AppError;
|
||||
|
||||
pub async fn connect_db(config: &Config) -> Result<Database, AppError> {
|
||||
info!("Connecting to MongoDB at: {}", config.database_url);
|
||||
|
||||
let mut client_options = ClientOptions::parse(&config.database_url).await?;
|
||||
client_options.app_name = Some("stick-template".to_string());
|
||||
|
||||
let client = Client::with_options(client_options)?;
|
||||
let db = client.database(&config.database_name);
|
||||
|
||||
// Ping to verify connection
|
||||
db.run_command(doc! { "ping": 1 }).await?;
|
||||
info!("Successfully connected to database: {}", config.database_name);
|
||||
|
||||
// Build index models
|
||||
setup_indexes(&db).await?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
async fn setup_indexes(db: &Database) -> Result<(), AppError> {
|
||||
info!("Setting up database indexes...");
|
||||
|
||||
// Setup unique index for user username
|
||||
let users_col = db.collection::<mongodb::bson::Document>("users");
|
||||
|
||||
let username_index = IndexModel::builder()
|
||||
.keys(doc! { "username": 1 })
|
||||
.options(IndexOptions::builder().unique(true).build())
|
||||
.build();
|
||||
|
||||
users_col.create_index(username_index).await?;
|
||||
|
||||
info!("Database index setup completed successfully.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("Database error: {0}")]
|
||||
Mongo(#[from] mongodb::error::Error),
|
||||
|
||||
#[error("Crypto error: {0}")]
|
||||
Bcrypt(#[from] bcrypt::BcryptError),
|
||||
|
||||
#[error("Authentication token error: {0}")]
|
||||
Jwt(#[from] jsonwebtoken::errors::Error),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Bad Request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Internal Server Error: {0}")]
|
||||
#[allow(dead_code)]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, msg) = match &self {
|
||||
AppError::Mongo(err) => {
|
||||
tracing::error!("Database Error: {:?}", err);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "A database error occurred.")
|
||||
}
|
||||
AppError::Bcrypt(err) => {
|
||||
tracing::error!("Password hashing error: {:?}", err);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "An authentication hashing error occurred.")
|
||||
}
|
||||
AppError::Jwt(err) => {
|
||||
tracing::error!("Token signing/validation error: {:?}", err);
|
||||
(StatusCode::UNAUTHORIZED, "Your session has expired or is invalid. Please log in again.")
|
||||
}
|
||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.as_str()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
|
||||
AppError::Internal(msg) => {
|
||||
tracing::error!("Internal Error: {}", msg);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, msg.as_str())
|
||||
}
|
||||
};
|
||||
|
||||
// Render a premium Tailwind styled error page
|
||||
let html_content = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - Template App</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {{
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0f172a] text-slate-100 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full bg-[#1e293b] border border-slate-800 rounded-2xl p-8 text-center shadow-2xl relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-red-500 via-rose-500 to-pink-600"></div>
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-rose-950/50 border border-rose-900/50 text-rose-500 mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-100 mb-2">Something went wrong</h1>
|
||||
<p class="text-slate-400 text-sm mb-6 leading-relaxed">{}</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<a href="/" class="px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-750 transition font-medium text-sm text-slate-200 border border-slate-700">
|
||||
Go to Safety
|
||||
</a>
|
||||
<a href="/auth/login" class="px-5 py-2.5 rounded-xl bg-gradient-to-r from-sky-500 to-indigo-600 hover:opacity-90 transition font-medium text-sm text-white">
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
msg
|
||||
);
|
||||
|
||||
(status, Html(html_content)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod database;
|
||||
pub mod errors;
|
||||
@@ -0,0 +1,209 @@
|
||||
use axum::response::IntoResponse;
|
||||
use askama::Template;
|
||||
use crate::common::errors::AppError;
|
||||
use crate::auth::extractors::AuthenticatedUser;
|
||||
|
||||
// Wrapper for rendering Askama HTML
|
||||
pub struct HtmlTemplate<T>(pub T);
|
||||
|
||||
impl<T> IntoResponse for HtmlTemplate<T>
|
||||
where
|
||||
T: Template,
|
||||
{
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self.0.render() {
|
||||
Ok(html) => axum::response::Html(html).into_response(),
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to render template: {:?}", err);
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Macro helper to construct user session details
|
||||
fn get_session_info(user_opt: Option<AuthenticatedUser>) -> (bool, String) {
|
||||
match user_opt {
|
||||
Some(user) => (true, user.username),
|
||||
None => (false, "".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// Define individual templates
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/index.html")]
|
||||
pub struct IndexTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/buttons.html")]
|
||||
pub struct ButtonsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/inputs.html")]
|
||||
pub struct InputsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/date_time.html")]
|
||||
pub struct DateTimeTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/combobox.html")]
|
||||
pub struct ComboboxTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/toggles.html")]
|
||||
pub struct TogglesTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/modals.html")]
|
||||
pub struct ModalsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/sheets.html")]
|
||||
pub struct SheetsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/tabs_accordion.html")]
|
||||
pub struct TabsAccordionTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/visuals.html")]
|
||||
pub struct VisualsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/scrollbars.html")]
|
||||
pub struct ScrollbarsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/feedback.html")]
|
||||
pub struct FeedbackTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
// Define individual handlers
|
||||
pub async fn index_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(IndexTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn buttons_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(ButtonsTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn inputs_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(InputsTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn date_time_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(DateTimeTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn combobox_handler(
|
||||
axum::extract::State(dev_repo): axum::extract::State<crate::developers::repository::MongoDeveloperRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
use crate::developers::repository::DeveloperRepository;
|
||||
|
||||
let user_id = user_opt.as_ref().map(|u| u.user_id);
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
|
||||
if let Some(user_id) = user_id {
|
||||
let _ = dev_repo.ensure_seeded(&user_id).await;
|
||||
}
|
||||
|
||||
Ok(HtmlTemplate(ComboboxTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn toggles_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(TogglesTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn modals_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(ModalsTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn sheets_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(SheetsTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn tabs_accordion_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(TabsAccordionTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn visuals_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(VisualsTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn scrollbars_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(ScrollbarsTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn feedback_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(FeedbackTemplate { username, authenticated }))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
pub mod handlers;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use self::handlers::{
|
||||
index_handler, buttons_handler, inputs_handler, date_time_handler,
|
||||
combobox_handler, toggles_handler, modals_handler, sheets_handler,
|
||||
tabs_accordion_handler, visuals_handler, scrollbars_handler, feedback_handler,
|
||||
};
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
crate::common::config::Config: axum::extract::FromRef<S>,
|
||||
mongodb::Database: axum::extract::FromRef<S>,
|
||||
crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/components", get(index_handler))
|
||||
.route("/components/buttons", get(buttons_handler))
|
||||
.route("/components/inputs", get(inputs_handler))
|
||||
.route("/components/date-time", get(date_time_handler))
|
||||
.route("/components/combobox", get(combobox_handler))
|
||||
.route("/components/toggles", get(toggles_handler))
|
||||
.route("/components/modals", get(modals_handler))
|
||||
.route("/components/sheets", get(sheets_handler))
|
||||
.route("/components/tabs-accordion", get(tabs_accordion_handler))
|
||||
.route("/components/visuals", get(visuals_handler))
|
||||
.route("/components/scrollbars", get(scrollbars_handler))
|
||||
.route("/components/feedback", get(feedback_handler))
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Form, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::Deserialize;
|
||||
use crate::common::errors::AppError;
|
||||
use crate::auth::extractors::AuthenticatedUser;
|
||||
use crate::developers::models::Developer;
|
||||
use crate::developers::repository::{DeveloperRepository, MongoDeveloperRepository};
|
||||
|
||||
// Wrapper for rendering Askama HTML
|
||||
struct HtmlTemplate<T>(T);
|
||||
|
||||
impl<T> IntoResponse for HtmlTemplate<T>
|
||||
where
|
||||
T: Template,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
match self.0.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to render template: {:?}", err);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Askama templates
|
||||
#[derive(Template)]
|
||||
#[template(path = "developers/list.html")]
|
||||
struct DeveloperListTemplate {
|
||||
username: String,
|
||||
authenticated: bool,
|
||||
developers: Vec<Developer>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "developers/edit.html")]
|
||||
struct DeveloperEditTemplate {
|
||||
username: String,
|
||||
authenticated: bool,
|
||||
developer: Developer,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "developers/search_results.html")]
|
||||
struct DeveloperSearchResultsTemplate {
|
||||
developers: Vec<Developer>,
|
||||
}
|
||||
|
||||
// HANDLERS
|
||||
|
||||
pub async fn get_list(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(Redirect::to("/auth/login").into_response());
|
||||
};
|
||||
|
||||
let _ = dev_repo.ensure_seeded(&user.user_id).await;
|
||||
let developers = dev_repo.find_all_by_user(&user.user_id).await?;
|
||||
|
||||
Ok(HtmlTemplate(DeveloperListTemplate {
|
||||
username: user.username,
|
||||
authenticated: true,
|
||||
developers,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateDevForm {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub skills: String,
|
||||
}
|
||||
|
||||
pub async fn post_create(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Form(payload): Form<CreateDevForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(Redirect::to("/auth/login").into_response());
|
||||
};
|
||||
|
||||
let name = payload.name.trim();
|
||||
let email = payload.email.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::BadRequest("Developer name cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let skills: Vec<String> = payload.skills
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
dev_repo.create(&user.user_id, name, email, skills).await?;
|
||||
|
||||
Ok(Redirect::to("/developers").into_response())
|
||||
}
|
||||
|
||||
pub async fn get_edit(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(dev_id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(Redirect::to("/auth/login").into_response());
|
||||
};
|
||||
|
||||
let dev_id = ObjectId::parse_str(&dev_id_str)
|
||||
.map_err(|_| AppError::BadRequest("Invalid developer identifier".to_string()))?;
|
||||
|
||||
let developer = dev_repo.find_by_id(&dev_id, &user.user_id).await?;
|
||||
let Some(developer) = developer else {
|
||||
return Err(AppError::Unauthorized("Developer not found".to_string()));
|
||||
};
|
||||
|
||||
Ok(HtmlTemplate(DeveloperEditTemplate {
|
||||
username: user.username,
|
||||
authenticated: true,
|
||||
developer,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn post_update(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(dev_id_str): Path<String>,
|
||||
Form(payload): Form<CreateDevForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(Redirect::to("/auth/login").into_response());
|
||||
};
|
||||
|
||||
let dev_id = ObjectId::parse_str(&dev_id_str)
|
||||
.map_err(|_| AppError::BadRequest("Invalid developer identifier".to_string()))?;
|
||||
|
||||
let name = payload.name.trim();
|
||||
let email = payload.email.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::BadRequest("Developer name cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let skills: Vec<String> = payload.skills
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
dev_repo.update(&dev_id, &user.user_id, name, email, skills).await?;
|
||||
|
||||
Ok(Redirect::to("/developers").into_response())
|
||||
}
|
||||
|
||||
pub async fn post_delete(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(dev_id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(Redirect::to("/auth/login").into_response());
|
||||
};
|
||||
|
||||
let dev_id = ObjectId::parse_str(&dev_id_str)
|
||||
.map_err(|_| AppError::BadRequest("Invalid developer identifier".to_string()))?;
|
||||
|
||||
dev_repo.delete(&dev_id, &user.user_id).await?;
|
||||
|
||||
Ok(Redirect::to("/developers").into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub q: String,
|
||||
}
|
||||
|
||||
pub async fn get_search(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Query(params): Query<SearchQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(StatusCode::UNAUTHORIZED.into_response());
|
||||
};
|
||||
|
||||
let _ = dev_repo.ensure_seeded(&user.user_id).await;
|
||||
let query_str = params.q.trim();
|
||||
if query_str.is_empty() {
|
||||
return Ok(HtmlTemplate(DeveloperSearchResultsTemplate { developers: vec![] }).into_response());
|
||||
}
|
||||
|
||||
let matched_devs = dev_repo.search_by_name(&user.user_id, query_str).await?;
|
||||
|
||||
Ok(HtmlTemplate(DeveloperSearchResultsTemplate {
|
||||
developers: matched_devs,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod repository;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use crate::common::config::Config;
|
||||
use crate::developers::repository::MongoDeveloperRepository;
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
Config: axum::extract::FromRef<S>,
|
||||
MongoDeveloperRepository: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/developers", get(handlers::get_list).post(handlers::post_create))
|
||||
.route("/developers/{id}/edit", get(handlers::get_edit).post(handlers::post_update))
|
||||
.route("/developers/{id}/delete", post(handlers::post_delete))
|
||||
.route("/developers/search", get(handlers::get_search))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::serde_as;
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Developer {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
pub user_id: ObjectId,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub skills: Vec<String>,
|
||||
#[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")]
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use futures::stream::TryStreamExt;
|
||||
use mongodb::{
|
||||
bson::{doc, oid::ObjectId},
|
||||
options::FindOptions,
|
||||
Database,
|
||||
};
|
||||
use crate::common::errors::AppError;
|
||||
use crate::developers::models::Developer;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait DeveloperRepository {
|
||||
async fn find_all_by_user(&self, user_id: &ObjectId) -> Result<Vec<Developer>, AppError>;
|
||||
async fn find_by_id(&self, dev_id: &ObjectId, user_id: &ObjectId) -> Result<Option<Developer>, AppError>;
|
||||
async fn search_by_name(&self, user_id: &ObjectId, query: &str) -> Result<Vec<Developer>, AppError>;
|
||||
async fn create(&self, user_id: &ObjectId, name: &str, email: &str, skills: Vec<String>) -> Result<Developer, AppError>;
|
||||
async fn update(&self, dev_id: &ObjectId, user_id: &ObjectId, name: &str, email: &str, skills: Vec<String>) -> Result<(), AppError>;
|
||||
async fn delete(&self, dev_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError>;
|
||||
async fn ensure_seeded(&self, user_id: &ObjectId) -> Result<(), AppError>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MongoDeveloperRepository {
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl MongoDeveloperRepository {
|
||||
pub fn new(db: Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DeveloperRepository for MongoDeveloperRepository {
|
||||
async fn find_all_by_user(&self, user_id: &ObjectId) -> Result<Vec<Developer>, AppError> {
|
||||
let collection = self.db.collection::<Developer>("developers");
|
||||
let filter = doc! { "user_id": user_id };
|
||||
let find_options = FindOptions::builder().sort(doc! { "created_at": -1 }).build();
|
||||
|
||||
let mut cursor = collection.find(filter).with_options(find_options).await?;
|
||||
let mut developers = Vec::new();
|
||||
while let Some(dev) = cursor.try_next().await? {
|
||||
developers.push(dev);
|
||||
}
|
||||
Ok(developers)
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, dev_id: &ObjectId, user_id: &ObjectId) -> Result<Option<Developer>, AppError> {
|
||||
let collection = self.db.collection::<Developer>("developers");
|
||||
let filter = doc! { "_id": dev_id, "user_id": user_id };
|
||||
let dev = collection.find_one(filter).await?;
|
||||
Ok(dev)
|
||||
}
|
||||
|
||||
async fn search_by_name(&self, user_id: &ObjectId, query: &str) -> Result<Vec<Developer>, AppError> {
|
||||
let collection = self.db.collection::<Developer>("developers");
|
||||
// Regex search case-insensitive on developer name
|
||||
let filter = doc! {
|
||||
"user_id": user_id,
|
||||
"name": { "$regex": query, "$options": "i" }
|
||||
};
|
||||
let find_options = FindOptions::builder().limit(10).build();
|
||||
|
||||
let mut cursor = collection.find(filter).with_options(find_options).await?;
|
||||
let mut developers = Vec::new();
|
||||
while let Some(dev) = cursor.try_next().await? {
|
||||
developers.push(dev);
|
||||
}
|
||||
Ok(developers)
|
||||
}
|
||||
|
||||
async fn create(&self, user_id: &ObjectId, name: &str, email: &str, skills: Vec<String>) -> Result<Developer, AppError> {
|
||||
let collection = self.db.collection::<Developer>("developers");
|
||||
let new_dev = Developer {
|
||||
id: None,
|
||||
user_id: *user_id,
|
||||
name: name.to_string(),
|
||||
email: email.to_string(),
|
||||
skills,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
let insert_result = collection.insert_one(new_dev.clone()).await?;
|
||||
let mut dev = new_dev;
|
||||
dev.id = Some(insert_result.inserted_id.as_object_id().expect("Inserted ID is ObjectId"));
|
||||
Ok(dev)
|
||||
}
|
||||
|
||||
async fn update(&self, dev_id: &ObjectId, user_id: &ObjectId, name: &str, email: &str, skills: Vec<String>) -> Result<(), AppError> {
|
||||
let collection = self.db.collection::<Developer>("developers");
|
||||
let filter = doc! { "_id": dev_id, "user_id": user_id };
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"name": name,
|
||||
"email": email,
|
||||
"skills": skills
|
||||
}
|
||||
};
|
||||
let result = collection.update_one(filter, update).await?;
|
||||
if result.matched_count == 0 {
|
||||
return Err(AppError::Unauthorized("Developer not found or not owned by user".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, dev_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError> {
|
||||
let collection = self.db.collection::<Developer>("developers");
|
||||
let filter = doc! { "_id": dev_id, "user_id": user_id };
|
||||
let result = collection.delete_one(filter).await?;
|
||||
if result.deleted_count == 0 {
|
||||
return Err(AppError::Unauthorized("Developer not found or not owned by user".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_seeded(&self, user_id: &ObjectId) -> Result<(), AppError> {
|
||||
let devs = self.find_all_by_user(user_id).await?;
|
||||
if devs.is_empty() {
|
||||
let _ = self.create(user_id, "Alice Vance", "alice@example.com", vec!["Rust".to_string(), "Axum".to_string()]).await;
|
||||
let _ = self.create(user_id, "Bob Carter", "bob@example.com", vec!["Tailwind".to_string(), "JavaScript".to_string()]).await;
|
||||
let _ = self.create(user_id, "Charlie Smith", "charlie@example.com", vec!["HTML".to_string(), "CSS".to_string()]).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Outfit', sans-serif;
|
||||
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
html, body {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Custom Scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border-radius: 9999px;
|
||||
border: 1px solid transparent;
|
||||
background-clip: padding-box;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Custom Date/Time Inputs */
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
input[type="datetime-local"] {
|
||||
position: relative;
|
||||
padding-left: 0.5rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
color-scheme: dark;
|
||||
background-color: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit {
|
||||
padding: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
::-webkit-calendar-picker-indicator {
|
||||
position: absolute;
|
||||
right: 0.4rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-text {
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-month-field:focus,
|
||||
::-webkit-datetime-edit-day-field:focus,
|
||||
::-webkit-datetime-edit-year-field:focus {
|
||||
background-color: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Number Spinner Removal */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* Custom Select Arrows */
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='hsl(215.4, 16.3%, 56.9%)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 1rem;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
/* Hide scrollbars for premium list picker layouts */
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Edge */
|
||||
}
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
mod common;
|
||||
mod auth;
|
||||
mod tasks;
|
||||
mod developers;
|
||||
mod main_view;
|
||||
mod components;
|
||||
|
||||
use axum::{extract::FromRef, Router};
|
||||
use std::net::SocketAddr;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::common::config::Config;
|
||||
use crate::common::database::connect_db;
|
||||
use crate::auth::repository::MongoUserRepository;
|
||||
use crate::tasks::repository::MongoTaskRepository;
|
||||
use crate::developers::repository::MongoDeveloperRepository;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
config: Config,
|
||||
db: mongodb::Database,
|
||||
user_repo: MongoUserRepository,
|
||||
task_repo: MongoTaskRepository,
|
||||
dev_repo: MongoDeveloperRepository,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Config {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.config.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for mongodb::Database {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.db.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for MongoUserRepository {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.user_repo.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for MongoTaskRepository {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.task_repo.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for MongoDeveloperRepository {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.dev_repo.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Initialize logging
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
info!("Starting Stick Template application...");
|
||||
|
||||
// 2. Parse config from env
|
||||
let config = Config::from_env();
|
||||
|
||||
// 3. Connect to MongoDB
|
||||
let db = connect_db(&config).await?;
|
||||
|
||||
// 4. Initialize repositories
|
||||
let user_repo = MongoUserRepository::new(db.clone());
|
||||
let task_repo = MongoTaskRepository::new(db.clone());
|
||||
let dev_repo = MongoDeveloperRepository::new(db.clone());
|
||||
|
||||
// 5. Initialize shared AppState
|
||||
let state = AppState {
|
||||
config: config.clone(),
|
||||
db,
|
||||
user_repo,
|
||||
task_repo,
|
||||
dev_repo,
|
||||
};
|
||||
|
||||
// 6. Build and merge routers by use-case
|
||||
let app = Router::new()
|
||||
.merge(main_view::router())
|
||||
.merge(components::router())
|
||||
.merge(auth::router())
|
||||
.merge(tasks::router())
|
||||
.merge(developers::router())
|
||||
.with_state(state);
|
||||
|
||||
// 7. Bind address and run server
|
||||
let host_addr: SocketAddr = format!("{}:{}", config.host, config.port)
|
||||
.parse()
|
||||
.expect("Invalid HOST or PORT config");
|
||||
|
||||
info!("Listening on http://{}", host_addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(host_addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use axum::{routing::get, Router};
|
||||
use crate::common::errors::AppError;
|
||||
use crate::auth::extractors::AuthenticatedUser;
|
||||
use axum::response::IntoResponse;
|
||||
use askama::Template;
|
||||
|
||||
// Define the template struct extending base.html
|
||||
#[derive(Template)]
|
||||
#[template(path = "main_view/index.html")]
|
||||
struct IndexTemplate {
|
||||
username: String,
|
||||
authenticated: bool,
|
||||
}
|
||||
|
||||
async fn index_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = match user_opt {
|
||||
Some(user) => (true, user.username),
|
||||
None => (false, "".to_string()),
|
||||
};
|
||||
|
||||
Ok(HtmlTemplate(IndexTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
// Wrapper for rendering Askama HTML
|
||||
struct HtmlTemplate<T>(T);
|
||||
|
||||
impl<T> IntoResponse for HtmlTemplate<T>
|
||||
where
|
||||
T: Template,
|
||||
{
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self.0.render() {
|
||||
Ok(html) => axum::response::Html(html).into_response(),
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to render template: {:?}", err);
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_tailwind() -> impl IntoResponse {
|
||||
let css = include_str!("../../static/tailwind.css");
|
||||
(
|
||||
[(axum::http::header::CONTENT_TYPE, "text/css")],
|
||||
css
|
||||
)
|
||||
}
|
||||
|
||||
async fn serve_combobox_js() -> impl IntoResponse {
|
||||
let js = include_str!("../../static/js/combobox.js");
|
||||
(
|
||||
[(axum::http::header::CONTENT_TYPE, "application/javascript")],
|
||||
js
|
||||
)
|
||||
}
|
||||
|
||||
async fn serve_components_js() -> impl IntoResponse {
|
||||
let js = include_str!("../../static/js/components.js");
|
||||
(
|
||||
[(axum::http::header::CONTENT_TYPE, "application/javascript")],
|
||||
js
|
||||
)
|
||||
}
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
crate::common::config::Config: axum::extract::FromRef<S>,
|
||||
mongodb::Database: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/static/tailwind.css", get(serve_tailwind))
|
||||
.route("/static/js/combobox.js", get(serve_combobox_js))
|
||||
.route("/static/js/components.js", get(serve_components_js))
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Form, Path, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::Deserialize;
|
||||
use crate::common::errors::AppError;
|
||||
use crate::auth::extractors::AuthenticatedUser;
|
||||
use crate::tasks::models::Task;
|
||||
use crate::tasks::repository::{TaskRepository, MongoTaskRepository};
|
||||
use crate::developers::repository::{DeveloperRepository, MongoDeveloperRepository};
|
||||
|
||||
// Wrapper for rendering Askama HTML
|
||||
struct HtmlTemplate<T>(T);
|
||||
|
||||
impl<T> IntoResponse for HtmlTemplate<T>
|
||||
where
|
||||
T: Template,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
match self.0.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to render template: {:?}", err);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TaskViewModel {
|
||||
pub task: Task,
|
||||
pub developer_name: Option<String>,
|
||||
}
|
||||
|
||||
// Askama template struct
|
||||
#[derive(Template)]
|
||||
#[template(path = "tasks/dashboard.html")]
|
||||
struct DashboardTemplate {
|
||||
username: String,
|
||||
authenticated: bool,
|
||||
tasks: Vec<TaskViewModel>,
|
||||
}
|
||||
|
||||
// HANDLERS
|
||||
|
||||
pub async fn get_dashboard(
|
||||
State(task_repo): State<MongoTaskRepository>,
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(Redirect::to("/auth/login").into_response());
|
||||
};
|
||||
|
||||
let _ = dev_repo.ensure_seeded(&user.user_id).await;
|
||||
let tasks = task_repo.find_all_by_user(&user.user_id).await?;
|
||||
let mut task_vms = Vec::new();
|
||||
for task in tasks {
|
||||
let developer_name = if let Some(dev_id) = task.assigned_to {
|
||||
dev_repo.find_by_id(&dev_id, &user.user_id).await?.map(|d| d.name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
task_vms.push(TaskViewModel {
|
||||
task,
|
||||
developer_name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(HtmlTemplate(DashboardTemplate {
|
||||
username: user.username,
|
||||
authenticated: true,
|
||||
tasks: task_vms,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTaskForm {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub assignee_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn post_create_task(
|
||||
State(task_repo): State<MongoTaskRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Form(payload): Form<CreateTaskForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(Redirect::to("/auth/login").into_response());
|
||||
};
|
||||
|
||||
let title = payload.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(AppError::BadRequest("Task title cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let description = payload.description.as_deref().map(|d| d.trim());
|
||||
|
||||
let assigned_to = match payload.assignee_id.as_deref() {
|
||||
Some(id_str) if !id_str.trim().is_empty() => {
|
||||
ObjectId::parse_str(id_str.trim()).ok()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
task_repo
|
||||
.create(&user.user_id, title, description, assigned_to)
|
||||
.await?;
|
||||
|
||||
Ok(Redirect::to("/tasks").into_response())
|
||||
}
|
||||
|
||||
pub async fn post_complete_task(
|
||||
State(task_repo): State<MongoTaskRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(task_id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(Redirect::to("/auth/login").into_response());
|
||||
};
|
||||
|
||||
let task_id = ObjectId::parse_str(&task_id_str)
|
||||
.map_err(|_| AppError::BadRequest("Invalid task identifier".to_string()))?;
|
||||
|
||||
task_repo.mark_completed(&task_id, &user.user_id).await?;
|
||||
|
||||
Ok(Redirect::to("/tasks").into_response())
|
||||
}
|
||||
|
||||
pub async fn post_delete_task(
|
||||
State(task_repo): State<MongoTaskRepository>,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(task_id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
return Ok(Redirect::to("/auth/login").into_response());
|
||||
};
|
||||
|
||||
let task_id = ObjectId::parse_str(&task_id_str)
|
||||
.map_err(|_| AppError::BadRequest("Invalid task identifier".to_string()))?;
|
||||
|
||||
task_repo.delete(&task_id, &user.user_id).await?;
|
||||
|
||||
Ok(Redirect::to("/tasks").into_response())
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod repository;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use crate::common::config::Config;
|
||||
use crate::tasks::repository::MongoTaskRepository;
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
Config: axum::extract::FromRef<S>,
|
||||
MongoTaskRepository: axum::extract::FromRef<S>,
|
||||
crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/tasks", get(handlers::get_dashboard))
|
||||
.route("/tasks/create", post(handlers::post_create_task))
|
||||
.route("/tasks/{id}/complete", post(handlers::post_complete_task))
|
||||
.route("/tasks/{id}/delete", post(handlers::post_delete_task))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::serde_as;
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Task {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
pub user_id: ObjectId,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub is_completed: bool,
|
||||
#[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")]
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assigned_to: Option<ObjectId>,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use futures::stream::TryStreamExt;
|
||||
use mongodb::{
|
||||
bson::{doc, oid::ObjectId},
|
||||
options::FindOptions,
|
||||
Database,
|
||||
};
|
||||
use crate::common::errors::AppError;
|
||||
use crate::tasks::models::Task;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait TaskRepository {
|
||||
async fn find_all_by_user(&self, user_id: &ObjectId) -> Result<Vec<Task>, AppError>;
|
||||
async fn create(&self, user_id: &ObjectId, title: &str, description: Option<&str>, assigned_to: Option<ObjectId>) -> Result<Task, AppError>;
|
||||
async fn mark_completed(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError>;
|
||||
async fn delete(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MongoTaskRepository {
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl MongoTaskRepository {
|
||||
pub fn new(db: Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TaskRepository for MongoTaskRepository {
|
||||
async fn find_all_by_user(&self, user_id: &ObjectId) -> Result<Vec<Task>, AppError> {
|
||||
let collection = self.db.collection::<Task>("tasks");
|
||||
|
||||
let filter = doc! { "user_id": user_id };
|
||||
|
||||
// Sort incomplete tasks first, and then order by creation timestamp descending
|
||||
let find_options = FindOptions::builder()
|
||||
.sort(doc! { "is_completed": 1, "created_at": -1 })
|
||||
.build();
|
||||
|
||||
let mut cursor = collection.find(filter).with_options(find_options).await?;
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
while let Some(task) = cursor.try_next().await? {
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
async fn create(&self, user_id: &ObjectId, title: &str, description: Option<&str>, assigned_to: Option<ObjectId>) -> Result<Task, AppError> {
|
||||
let collection = self.db.collection::<Task>("tasks");
|
||||
|
||||
let new_task = Task {
|
||||
id: None,
|
||||
user_id: *user_id,
|
||||
title: title.to_string(),
|
||||
description: description.map(|d| d.to_string()),
|
||||
is_completed: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
assigned_to,
|
||||
};
|
||||
|
||||
let insert_result = collection.insert_one(new_task.clone()).await?;
|
||||
|
||||
let mut task = new_task;
|
||||
task.id = Some(insert_result.inserted_id.as_object_id().expect("Inserted ID is ObjectId"));
|
||||
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
async fn mark_completed(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError> {
|
||||
let collection = self.db.collection::<Task>("tasks");
|
||||
|
||||
let filter = doc! { "_id": task_id, "user_id": user_id };
|
||||
let update = doc! { "$set": { "is_completed": true } };
|
||||
|
||||
let result = collection.update_one(filter, update).await?;
|
||||
|
||||
if result.matched_count == 0 {
|
||||
return Err(AppError::Unauthorized("Task not found or not owned by user".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError> {
|
||||
let collection = self.db.collection::<Task>("tasks");
|
||||
|
||||
let filter = doc! { "_id": task_id, "user_id": user_id };
|
||||
let result = collection.delete_one(filter).await?;
|
||||
|
||||
if result.deleted_count == 0 {
|
||||
return Err(AppError::Unauthorized("Task not found or not owned by user".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user