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
+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(())
}
}