feat: initialize template shell and basic components

This commit is contained in:
2026-05-30 01:09:14 +05:00
commit f42a5f05b2
55 changed files with 13107 additions and 0 deletions
+87
View File
@@ -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,
}))
}
}
+207
View File
@@ -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()
}
+23
View File
@@ -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))
}
+21
View File
@@ -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,
}
+52
View File
@@ -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)
}
}
+42
View File
@@ -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,
}
}
}
+44
View File
@@ -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(())
}
+94
View File
@@ -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()
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod config;
pub mod database;
pub mod errors;
+209
View File
@@ -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 }))
}
+30
View File
@@ -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))
}
+207
View File
@@ -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())
}
+23
View File
@@ -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))
}
+16
View File
@@ -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>,
}
+123
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+79
View File
@@ -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))
}
+150
View File
@@ -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())
}
+24
View File
@@ -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))
}
+18
View File
@@ -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>,
}
+99
View File
@@ -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(())
}
}