feat: implement audit logs system, request extractor, admin log panel, and dedicated documentation
- Added an enterprise-grade, request-scoped AuditLogger extractor in Axum. - Configured MongoDB persistence for structured, replayable audit logs (capturing timestamp, user, action, type, payload snapshot, client IP with proxy header support, and User-Agent). - Created a live Administrator console at /auth/audit to filter and inspect log events. - Re-architected documentation by moving Design Wiki pages out of /components into a dedicated /docs route. - Published logging architecture documentation at /docs/logging.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
response::{Html, IntoResponse, Response},
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use crate::common::errors::AppError;
|
||||
use crate::auth::extractors::AuthenticatedUser;
|
||||
use crate::audit::repository::MongoAuditRepository;
|
||||
use crate::audit::models::AuditLog;
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct AuditQuery {
|
||||
pub username: Option<String>,
|
||||
pub action_type: Option<String>,
|
||||
pub entity_type: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "audit/list.html")]
|
||||
pub struct AuditListTemplate {
|
||||
pub authenticated: bool,
|
||||
pub username: String,
|
||||
pub logs: Vec<AuditLog>,
|
||||
pub query: AuditQuery,
|
||||
}
|
||||
|
||||
pub async fn get_audit_logs(
|
||||
user: AuthenticatedUser,
|
||||
State(audit_repo): State<MongoAuditRepository>,
|
||||
Query(query): Query<AuditQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only administrators can access audit logs".to_string()));
|
||||
}
|
||||
|
||||
let logs = audit_repo.find_filtered(
|
||||
query.username.as_deref(),
|
||||
query.action_type.as_deref(),
|
||||
query.entity_type.as_deref(),
|
||||
query.entity_id.as_deref(),
|
||||
query.start_date.as_deref(),
|
||||
query.end_date.as_deref(),
|
||||
).await?;
|
||||
|
||||
Ok(HtmlTemplate(AuditListTemplate {
|
||||
authenticated: true,
|
||||
username: user.username,
|
||||
logs,
|
||||
query,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
pub mod models;
|
||||
pub mod repository;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::{routing::get, Router, extract::FromRef};
|
||||
use crate::common::config::Config;
|
||||
use crate::audit::repository::MongoAuditRepository;
|
||||
use crate::auth::extractors::AuthenticatedUser;
|
||||
use crate::audit::models::AuditLog;
|
||||
use axum::extract::ConnectInfo;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
Config: axum::extract::FromRef<S>,
|
||||
MongoAuditRepository: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/auth/audit", get(handlers::get_audit_logs))
|
||||
}
|
||||
|
||||
/// Lower-level helper function to write audit log entries directly to the database.
|
||||
pub async fn log_action(
|
||||
db: &mongodb::Database,
|
||||
user_opt: Option<&AuthenticatedUser>,
|
||||
action_type: &str,
|
||||
entity_type: &str,
|
||||
entity_id: Option<mongodb::bson::oid::ObjectId>,
|
||||
details: Option<String>,
|
||||
payload: Option<serde_json::Value>,
|
||||
ip_address: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
) {
|
||||
let log = AuditLog {
|
||||
id: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
user_id: user_opt.map(|u| u.user_id),
|
||||
username: user_opt.map(|u| u.username.clone()),
|
||||
action_type: action_type.to_string(),
|
||||
entity_type: entity_type.to_string(),
|
||||
entity_id,
|
||||
details,
|
||||
payload,
|
||||
ip_address,
|
||||
user_agent,
|
||||
};
|
||||
|
||||
let repo = MongoAuditRepository::new(db.clone());
|
||||
if let Err(e) = repo.insert(log).await {
|
||||
tracing::error!("Failed to write audit log: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// A request-scoped extractor that automatically resolves request metadata (IP, User-Agent),
|
||||
/// retrieves the database connection, and captures the optional authenticated user.
|
||||
/// This pattern simplifies audit trail logging inside handler functions to a single line.
|
||||
pub struct AuditLogger {
|
||||
pub db: mongodb::Database,
|
||||
pub user: Option<AuthenticatedUser>,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl<S> axum::extract::FromRequestParts<S> for AuditLogger
|
||||
where
|
||||
mongodb::Database: axum::extract::FromRef<S>,
|
||||
Config: axum::extract::FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = crate::common::errors::AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let db = mongodb::Database::from_ref(state);
|
||||
|
||||
// Try to authenticate the user for this request. If it fails, user is None.
|
||||
let user = AuthenticatedUser::from_request_parts(parts, state).await.ok();
|
||||
|
||||
let user_agent = parts.headers.get(axum::http::header::USER_AGENT)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let socket_ip = parts.extensions.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ConnectInfo(addr)| addr.ip().to_string());
|
||||
|
||||
let proxy_ip = parts.headers.get("x-forwarded-for")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.or_else(|| {
|
||||
parts.headers.get("x-real-ip")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
});
|
||||
|
||||
let ip_address = match (proxy_ip, socket_ip) {
|
||||
(Some(p_ip), Some(s_ip)) => {
|
||||
if p_ip == s_ip {
|
||||
Some(p_ip)
|
||||
} else {
|
||||
Some(format!("{} (Socket: {})", p_ip, s_ip))
|
||||
}
|
||||
}
|
||||
(Some(p_ip), None) => Some(p_ip),
|
||||
(None, Some(s_ip)) => Some(s_ip),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
Ok(AuditLogger {
|
||||
db,
|
||||
user,
|
||||
ip_address,
|
||||
user_agent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AuditLogger {
|
||||
/// Logs an action for the currently authenticated user resolved by this request.
|
||||
pub async fn log(
|
||||
&self,
|
||||
action_type: &str,
|
||||
entity_type: &str,
|
||||
entity_id: Option<mongodb::bson::oid::ObjectId>,
|
||||
details: Option<String>,
|
||||
payload: Option<serde_json::Value>,
|
||||
) {
|
||||
log_action(
|
||||
&self.db,
|
||||
self.user.as_ref(),
|
||||
action_type,
|
||||
entity_type,
|
||||
entity_id,
|
||||
details,
|
||||
payload,
|
||||
self.ip_address.clone(),
|
||||
self.user_agent.clone(),
|
||||
).await;
|
||||
}
|
||||
|
||||
/// Logs an action with an explicitly provided user (e.g., during login sequences
|
||||
/// before the user token cookie is set, or for login failures).
|
||||
pub async fn log_with_user(
|
||||
&self,
|
||||
user: Option<&AuthenticatedUser>,
|
||||
action_type: &str,
|
||||
entity_type: &str,
|
||||
entity_id: Option<mongodb::bson::oid::ObjectId>,
|
||||
details: Option<String>,
|
||||
payload: Option<serde_json::Value>,
|
||||
) {
|
||||
log_action(
|
||||
&self.db,
|
||||
user,
|
||||
action_type,
|
||||
entity_type,
|
||||
entity_id,
|
||||
details,
|
||||
payload,
|
||||
self.ip_address.clone(),
|
||||
self.user_agent.clone(),
|
||||
).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::serde_as;
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AuditLog {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
|
||||
#[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")]
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
|
||||
pub user_id: Option<ObjectId>,
|
||||
pub username: Option<String>,
|
||||
pub action_type: String, // e.g., Login, Logout, Create, Update, Delete, View, Search
|
||||
pub entity_type: String, // e.g., User, Task, Developer, System
|
||||
|
||||
pub entity_id: Option<ObjectId>,
|
||||
pub details: Option<String>,
|
||||
|
||||
pub payload: Option<serde_json::Value>, // State snapshot / diff for replayability
|
||||
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl AuditLog {
|
||||
pub fn formatted_payload(&self) -> String {
|
||||
match &self.payload {
|
||||
Some(val) => serde_json::to_string_pretty(val).unwrap_or_else(|_| "".to_string()),
|
||||
None => "".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use mongodb::{
|
||||
bson::{doc, oid::ObjectId},
|
||||
Database,
|
||||
};
|
||||
use crate::common::errors::AppError;
|
||||
use crate::audit::models::AuditLog;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MongoAuditRepository {
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl MongoAuditRepository {
|
||||
pub fn new(db: Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn insert(&self, log: AuditLog) -> Result<(), AppError> {
|
||||
let collection = self.db.collection::<AuditLog>("audit_logs");
|
||||
collection.insert_one(log).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_filtered(
|
||||
&self,
|
||||
username: Option<&str>,
|
||||
action_type: Option<&str>,
|
||||
entity_type: Option<&str>,
|
||||
entity_id: Option<&str>,
|
||||
start_date: Option<&str>,
|
||||
end_date: Option<&str>,
|
||||
) -> Result<Vec<AuditLog>, AppError> {
|
||||
use futures::TryStreamExt;
|
||||
let collection = self.db.collection::<AuditLog>("audit_logs");
|
||||
let mut filter = doc! {};
|
||||
|
||||
if let Some(uname) = username {
|
||||
let trimmed = uname.trim();
|
||||
if !trimmed.is_empty() {
|
||||
filter.insert("username", doc! { "$regex": trimmed, "$options": "i" });
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(act) = action_type {
|
||||
let trimmed = act.trim();
|
||||
if !trimmed.is_empty() && trimmed != "all" {
|
||||
filter.insert("action_type", trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ent) = entity_type {
|
||||
let trimmed = ent.trim();
|
||||
if !trimmed.is_empty() && trimmed != "all" {
|
||||
filter.insert("entity_type", trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ent_id_str) = entity_id {
|
||||
let trimmed = ent_id_str.trim();
|
||||
if !trimmed.is_empty() {
|
||||
if let Ok(oid) = ObjectId::parse_str(trimmed) {
|
||||
filter.insert("entity_id", oid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Date filters range
|
||||
let mut date_query = doc! {};
|
||||
|
||||
if let Some(start) = start_date {
|
||||
let trimmed = start.trim();
|
||||
if !trimmed.is_empty() {
|
||||
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
|
||||
if let Some(naive_datetime) = naive_date.and_hms_opt(0, 0, 0) {
|
||||
let dt = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive_datetime, chrono::Utc);
|
||||
date_query.insert("$gte", mongodb::bson::DateTime::from_chrono(dt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(end) = end_date {
|
||||
let trimmed = end.trim();
|
||||
if !trimmed.is_empty() {
|
||||
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
|
||||
if let Some(naive_datetime) = naive_date.and_hms_opt(23, 59, 59) {
|
||||
let dt = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive_datetime, chrono::Utc);
|
||||
date_query.insert("$lte", mongodb::bson::DateTime::from_chrono(dt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !date_query.is_empty() {
|
||||
filter.insert("timestamp", date_query);
|
||||
}
|
||||
|
||||
let find_options = mongodb::options::FindOptions::builder()
|
||||
.sort(doc! { "timestamp": -1 })
|
||||
.limit(1000)
|
||||
.build();
|
||||
|
||||
let cursor = collection.find(filter).with_options(find_options).await?;
|
||||
let logs = cursor.try_collect::<Vec<AuditLog>>().await?;
|
||||
Ok(logs)
|
||||
}
|
||||
}
|
||||
+129
-3
@@ -4,6 +4,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use crate::audit::AuditLogger;
|
||||
use axum_extra::extract::{
|
||||
cookie::{Cookie, SameSite},
|
||||
CookieJar,
|
||||
@@ -79,6 +80,7 @@ pub struct AuthPayload {
|
||||
pub async fn post_login(
|
||||
State(config): State<Config>,
|
||||
State(user_repo): State<MongoUserRepository>,
|
||||
audit: AuditLogger,
|
||||
jar: CookieJar,
|
||||
Form(payload): Form<AuthPayload>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -87,6 +89,14 @@ pub async fn post_login(
|
||||
// Find user
|
||||
let user = user_repo.find_by_username(username).await?;
|
||||
let Some(user) = user else {
|
||||
audit.log_with_user(
|
||||
None,
|
||||
"Login",
|
||||
"User",
|
||||
None,
|
||||
Some(format!("Failed login attempt (unknown username): {}", username)),
|
||||
None
|
||||
).await;
|
||||
return Ok(HtmlTemplate(LoginTemplate {
|
||||
error: Some("Invalid username or password".to_string()),
|
||||
authenticated: false,
|
||||
@@ -99,6 +109,14 @@ pub async fn post_login(
|
||||
match verify(&payload.password, &user.password_hash) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
audit.log_with_user(
|
||||
None,
|
||||
"Login",
|
||||
"User",
|
||||
user.id,
|
||||
Some(format!("Failed login attempt (incorrect password) for user: {}", username)),
|
||||
None
|
||||
).await;
|
||||
return Ok(HtmlTemplate(LoginTemplate {
|
||||
error: Some("Invalid username or password".to_string()),
|
||||
authenticated: false,
|
||||
@@ -116,7 +134,7 @@ pub async fn post_login(
|
||||
|
||||
let claims = Claims {
|
||||
sub: user.id.expect("User document must have ID").to_hex(),
|
||||
username: user.username,
|
||||
username: user.username.clone(),
|
||||
is_admin: user.is_admin,
|
||||
exp,
|
||||
};
|
||||
@@ -127,6 +145,21 @@ pub async fn post_login(
|
||||
&EncodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||
)?;
|
||||
|
||||
// Log success
|
||||
let session_user = AuthenticatedUser {
|
||||
user_id: user.id.unwrap(),
|
||||
username: user.username.clone(),
|
||||
is_admin: user.is_admin,
|
||||
};
|
||||
audit.log_with_user(
|
||||
Some(&session_user),
|
||||
"Login",
|
||||
"User",
|
||||
user.id,
|
||||
Some(format!("Successful login for user: {}", username)),
|
||||
None
|
||||
).await;
|
||||
|
||||
// Set cookie
|
||||
let cookie = Cookie::build(("token", token))
|
||||
.path("/")
|
||||
@@ -164,6 +197,7 @@ pub struct RegisterPayload {
|
||||
pub async fn post_register(
|
||||
user: AuthenticatedUser,
|
||||
State(user_repo): State<MongoUserRepository>,
|
||||
audit: AuditLogger,
|
||||
Form(payload): Form<RegisterPayload>,
|
||||
) -> Result<Response, AppError> {
|
||||
if !user.is_admin {
|
||||
@@ -200,7 +234,20 @@ pub async fn post_register(
|
||||
|
||||
// Create user
|
||||
let is_admin_val = payload.is_admin.is_some();
|
||||
user_repo.create(username, &hashed_password, is_admin_val).await?;
|
||||
let new_user = user_repo.create(username, &hashed_password, is_admin_val).await?;
|
||||
|
||||
let payload_val = serde_json::json!({
|
||||
"username": new_user.username,
|
||||
"is_admin": new_user.is_admin,
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Create",
|
||||
"User",
|
||||
new_user.id,
|
||||
Some(format!("Registered new user: {}", username)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
|
||||
Ok(HtmlTemplate(RegisterTemplate {
|
||||
error: None,
|
||||
@@ -211,7 +258,21 @@ pub async fn post_register(
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn post_logout(jar: CookieJar) -> impl IntoResponse {
|
||||
pub async fn post_logout(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
audit: AuditLogger,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(user) = &user_opt {
|
||||
audit.log(
|
||||
"Logout",
|
||||
"User",
|
||||
Some(user.user_id),
|
||||
Some(format!("Logged out user: {}", user.username)),
|
||||
None
|
||||
).await;
|
||||
}
|
||||
|
||||
let mut cookie = Cookie::new("token", "");
|
||||
cookie.set_path("/");
|
||||
cookie.set_max_age(Some(Duration::ZERO)); // Clear cookie
|
||||
@@ -252,6 +313,7 @@ pub async fn get_password(
|
||||
pub async fn post_password(
|
||||
user: AuthenticatedUser,
|
||||
State(user_repo): State<MongoUserRepository>,
|
||||
audit: AuditLogger,
|
||||
Form(payload): Form<PasswordPayload>,
|
||||
) -> Result<Response, AppError> {
|
||||
let current_password = payload.current_password.trim();
|
||||
@@ -307,6 +369,14 @@ pub async fn post_password(
|
||||
// Update password in database
|
||||
user_repo.update_password(&user.user_id, &hashed_password).await?;
|
||||
|
||||
audit.log(
|
||||
"Update",
|
||||
"User",
|
||||
Some(user.user_id),
|
||||
Some(format!("Self-updated password for user: {}", user.username)),
|
||||
None
|
||||
).await;
|
||||
|
||||
Ok(HtmlTemplate(PasswordTemplate {
|
||||
error: None,
|
||||
success: Some("Password updated successfully!".to_string()),
|
||||
@@ -340,6 +410,7 @@ pub struct UserEditTemplate {
|
||||
pub async fn get_users(
|
||||
user: AuthenticatedUser,
|
||||
State(user_repo): State<MongoUserRepository>,
|
||||
audit: AuditLogger,
|
||||
) -> Result<Response, AppError> {
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only administrators can access user management".to_string()));
|
||||
@@ -347,6 +418,14 @@ pub async fn get_users(
|
||||
|
||||
let users = user_repo.find_all().await?;
|
||||
|
||||
audit.log(
|
||||
"View",
|
||||
"User",
|
||||
None,
|
||||
Some("Viewed list of all registered users".to_string()),
|
||||
None
|
||||
).await;
|
||||
|
||||
Ok(HtmlTemplate(UserListTemplate {
|
||||
authenticated: true,
|
||||
username: user.username,
|
||||
@@ -358,6 +437,7 @@ pub async fn get_users(
|
||||
pub async fn get_edit_user(
|
||||
user: AuthenticatedUser,
|
||||
State(user_repo): State<MongoUserRepository>,
|
||||
audit: AuditLogger,
|
||||
Path(id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
if !user.is_admin {
|
||||
@@ -370,6 +450,14 @@ pub async fn get_edit_user(
|
||||
let user_to_edit = user_repo.find_by_id(&oid).await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
|
||||
|
||||
audit.log(
|
||||
"View",
|
||||
"User",
|
||||
Some(oid),
|
||||
Some(format!("Viewed edit page for user: {}", user_to_edit.username)),
|
||||
None
|
||||
).await;
|
||||
|
||||
Ok(HtmlTemplate(UserEditTemplate {
|
||||
error: None,
|
||||
success: None,
|
||||
@@ -390,6 +478,7 @@ pub struct EditUserPayload {
|
||||
pub async fn post_edit_user(
|
||||
user: AuthenticatedUser,
|
||||
State(user_repo): State<MongoUserRepository>,
|
||||
audit: AuditLogger,
|
||||
Path(id_str): Path<String>,
|
||||
Form(payload): Form<EditUserPayload>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -456,6 +545,26 @@ pub async fn post_edit_user(
|
||||
let updated_user = user_repo.find_by_id(&oid).await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
|
||||
|
||||
let payload_val = serde_json::json!({
|
||||
"before": {
|
||||
"username": user_to_edit.username,
|
||||
"is_admin": user_to_edit.is_admin,
|
||||
},
|
||||
"after": {
|
||||
"username": updated_user.username,
|
||||
"is_admin": updated_user.is_admin,
|
||||
"password_reset": password_hash_opt.is_some(),
|
||||
}
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Update",
|
||||
"User",
|
||||
Some(oid),
|
||||
Some(format!("Updated details for user: {}", new_username)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
|
||||
Ok(HtmlTemplate(UserEditTemplate {
|
||||
error: None,
|
||||
success: Some("User updated successfully!".to_string()),
|
||||
@@ -469,6 +578,7 @@ pub async fn post_edit_user(
|
||||
pub async fn post_delete_user(
|
||||
user: AuthenticatedUser,
|
||||
State(user_repo): State<MongoUserRepository>,
|
||||
audit: AuditLogger,
|
||||
Path(id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
if !user.is_admin {
|
||||
@@ -483,7 +593,23 @@ pub async fn post_delete_user(
|
||||
return Err(AppError::BadRequest("You cannot delete your own account while logged in".to_string()));
|
||||
}
|
||||
|
||||
let user_to_delete = user_repo.find_by_id(&oid).await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
|
||||
|
||||
user_repo.delete(&oid).await?;
|
||||
|
||||
let payload_val = serde_json::json!({
|
||||
"username": user_to_delete.username,
|
||||
"is_admin": user_to_delete.is_admin,
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Delete",
|
||||
"User",
|
||||
Some(oid),
|
||||
Some(format!("Deleted user account: {}", user_to_delete.username)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
|
||||
Ok(Redirect::to("/auth/users").into_response())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub fn router<S>() -> Router<S>
|
||||
where
|
||||
Config: axum::extract::FromRef<S>,
|
||||
MongoUserRepository: axum::extract::FromRef<S>,
|
||||
mongodb::Database: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use crate::audit::AuditLogger;
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::Deserialize;
|
||||
use crate::common::errors::AppError;
|
||||
@@ -56,6 +57,7 @@ struct DeveloperSearchResultsTemplate {
|
||||
|
||||
pub async fn get_list(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
@@ -65,6 +67,14 @@ pub async fn get_list(
|
||||
let _ = dev_repo.ensure_seeded(&user.user_id).await;
|
||||
let developers = dev_repo.find_all_by_user(&user.user_id).await?;
|
||||
|
||||
audit.log(
|
||||
"View",
|
||||
"Developer",
|
||||
None,
|
||||
Some("Viewed list of all developers".to_string()),
|
||||
None
|
||||
).await;
|
||||
|
||||
Ok(HtmlTemplate(DeveloperListTemplate {
|
||||
username: user.username,
|
||||
authenticated: true,
|
||||
@@ -82,6 +92,7 @@ pub struct CreateDevForm {
|
||||
|
||||
pub async fn post_create(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Form(payload): Form<CreateDevForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -101,13 +112,28 @@ pub async fn post_create(
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
dev_repo.create(&user.user_id, name, email, skills).await?;
|
||||
let new_dev = dev_repo.create(&user.user_id, name, email, skills).await?;
|
||||
|
||||
let payload_val = serde_json::json!({
|
||||
"name": new_dev.name,
|
||||
"email": new_dev.email,
|
||||
"skills": new_dev.skills,
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Create",
|
||||
"Developer",
|
||||
new_dev.id,
|
||||
Some(format!("Created developer: {}", name)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
|
||||
Ok(Redirect::to("/developers").into_response())
|
||||
}
|
||||
|
||||
pub async fn get_edit(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(dev_id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -123,6 +149,14 @@ pub async fn get_edit(
|
||||
return Err(AppError::Unauthorized("Developer not found".to_string()));
|
||||
};
|
||||
|
||||
audit.log(
|
||||
"View",
|
||||
"Developer",
|
||||
Some(dev_id),
|
||||
Some(format!("Viewed edit page for developer: {}", developer.name)),
|
||||
None
|
||||
).await;
|
||||
|
||||
Ok(HtmlTemplate(DeveloperEditTemplate {
|
||||
username: user.username,
|
||||
authenticated: true,
|
||||
@@ -133,6 +167,7 @@ pub async fn get_edit(
|
||||
|
||||
pub async fn post_update(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(dev_id_str): Path<String>,
|
||||
Form(payload): Form<CreateDevForm>,
|
||||
@@ -156,13 +191,41 @@ pub async fn post_update(
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let dev_before = dev_repo.find_by_id(&dev_id, &user.user_id).await?;
|
||||
|
||||
dev_repo.update(&dev_id, &user.user_id, name, email, skills).await?;
|
||||
|
||||
let dev_after = dev_repo.find_by_id(&dev_id, &user.user_id).await?;
|
||||
|
||||
if let (Some(dbef), Some(daft)) = (dev_before, dev_after) {
|
||||
let payload_val = serde_json::json!({
|
||||
"before": {
|
||||
"name": dbef.name,
|
||||
"email": dbef.email,
|
||||
"skills": dbef.skills,
|
||||
},
|
||||
"after": {
|
||||
"name": daft.name,
|
||||
"email": daft.email,
|
||||
"skills": daft.skills,
|
||||
}
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Update",
|
||||
"Developer",
|
||||
Some(dev_id),
|
||||
Some(format!("Updated developer details for: {}", name)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
}
|
||||
|
||||
Ok(Redirect::to("/developers").into_response())
|
||||
}
|
||||
|
||||
pub async fn post_delete(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(dev_id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -173,8 +236,26 @@ pub async fn post_delete(
|
||||
let dev_id = ObjectId::parse_str(&dev_id_str)
|
||||
.map_err(|_| AppError::BadRequest("Invalid developer identifier".to_string()))?;
|
||||
|
||||
let dev_to_delete = dev_repo.find_by_id(&dev_id, &user.user_id).await?;
|
||||
|
||||
dev_repo.delete(&dev_id, &user.user_id).await?;
|
||||
|
||||
if let Some(dd) = dev_to_delete {
|
||||
let payload_val = serde_json::json!({
|
||||
"name": dd.name,
|
||||
"email": dd.email,
|
||||
"skills": dd.skills,
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Delete",
|
||||
"Developer",
|
||||
Some(dev_id),
|
||||
Some(format!("Deleted developer: {}", dd.name)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
}
|
||||
|
||||
Ok(Redirect::to("/developers").into_response())
|
||||
}
|
||||
|
||||
@@ -185,6 +266,7 @@ pub struct SearchQuery {
|
||||
|
||||
pub async fn get_search(
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Query(params): Query<SearchQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -200,6 +282,19 @@ pub async fn get_search(
|
||||
|
||||
let matched_devs = dev_repo.search_by_name(&user.user_id, query_str).await?;
|
||||
|
||||
let payload_val = serde_json::json!({
|
||||
"query": query_str,
|
||||
"results_count": matched_devs.len(),
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Search",
|
||||
"Developer",
|
||||
None,
|
||||
Some(format!("Searched developers with query: '{}'", query_str)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
|
||||
Ok(HtmlTemplate(DeveloperSearchResultsTemplate {
|
||||
developers: matched_devs,
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn router<S>() -> Router<S>
|
||||
where
|
||||
Config: axum::extract::FromRef<S>,
|
||||
MongoDeveloperRepository: axum::extract::FromRef<S>,
|
||||
mongodb::Database: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
|
||||
@@ -31,89 +31,96 @@ fn get_session_info(user_opt: Option<AuthenticatedUser>) -> (bool, String) {
|
||||
|
||||
// Define individual templates
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/index.html")]
|
||||
#[template(path = "docs/index.html")]
|
||||
pub struct IndexTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/buttons.html")]
|
||||
#[template(path = "docs/buttons.html")]
|
||||
pub struct ButtonsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/inputs.html")]
|
||||
#[template(path = "docs/inputs.html")]
|
||||
pub struct InputsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/date_time.html")]
|
||||
#[template(path = "docs/date_time.html")]
|
||||
pub struct DateTimeTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/combobox.html")]
|
||||
#[template(path = "docs/combobox.html")]
|
||||
pub struct ComboboxTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/toggles.html")]
|
||||
#[template(path = "docs/toggles.html")]
|
||||
pub struct TogglesTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/modals.html")]
|
||||
#[template(path = "docs/modals.html")]
|
||||
pub struct ModalsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/sheets.html")]
|
||||
#[template(path = "docs/sheets.html")]
|
||||
pub struct SheetsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/tabs_accordion.html")]
|
||||
#[template(path = "docs/tabs_accordion.html")]
|
||||
pub struct TabsAccordionTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/visuals.html")]
|
||||
#[template(path = "docs/visuals.html")]
|
||||
pub struct VisualsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/scrollbars.html")]
|
||||
#[template(path = "docs/scrollbars.html")]
|
||||
pub struct ScrollbarsTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "components/feedback.html")]
|
||||
#[template(path = "docs/feedback.html")]
|
||||
pub struct FeedbackTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "docs/logging.html")]
|
||||
pub struct LoggingTemplate {
|
||||
pub username: String,
|
||||
pub authenticated: bool,
|
||||
}
|
||||
|
||||
// Define individual handlers
|
||||
pub async fn index_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
@@ -207,3 +214,10 @@ pub async fn feedback_handler(
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(FeedbackTemplate { username, authenticated }))
|
||||
}
|
||||
|
||||
pub async fn logging_handler(
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (authenticated, username) = get_session_info(user_opt);
|
||||
Ok(HtmlTemplate(LoggingTemplate { username, authenticated }))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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,
|
||||
logging_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("/docs", get(index_handler))
|
||||
.route("/docs/buttons", get(buttons_handler))
|
||||
.route("/docs/inputs", get(inputs_handler))
|
||||
.route("/docs/date-time", get(date_time_handler))
|
||||
.route("/docs/combobox", get(combobox_handler))
|
||||
.route("/docs/toggles", get(toggles_handler))
|
||||
.route("/docs/modals", get(modals_handler))
|
||||
.route("/docs/sheets", get(sheets_handler))
|
||||
.route("/docs/tabs-accordion", get(tabs_accordion_handler))
|
||||
.route("/docs/visuals", get(visuals_handler))
|
||||
.route("/docs/scrollbars", get(scrollbars_handler))
|
||||
.route("/docs/feedback", get(feedback_handler))
|
||||
.route("/docs/logging", get(logging_handler))
|
||||
}
|
||||
+15
-3
@@ -1,9 +1,10 @@
|
||||
mod common;
|
||||
mod auth;
|
||||
mod tasks;
|
||||
mod audit;
|
||||
mod developers;
|
||||
mod main_view;
|
||||
mod components;
|
||||
mod docs;
|
||||
|
||||
use axum::{extract::FromRef, Router};
|
||||
use std::net::SocketAddr;
|
||||
@@ -15,6 +16,7 @@ use crate::common::database::connect_db;
|
||||
use crate::auth::repository::MongoUserRepository;
|
||||
use crate::tasks::repository::MongoTaskRepository;
|
||||
use crate::developers::repository::MongoDeveloperRepository;
|
||||
use crate::audit::repository::MongoAuditRepository;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
@@ -23,6 +25,7 @@ struct AppState {
|
||||
user_repo: MongoUserRepository,
|
||||
task_repo: MongoTaskRepository,
|
||||
dev_repo: MongoDeveloperRepository,
|
||||
audit_repo: MongoAuditRepository,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Config {
|
||||
@@ -55,6 +58,12 @@ impl FromRef<AppState> for MongoDeveloperRepository {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for MongoAuditRepository {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.audit_repo.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Initialize logging
|
||||
@@ -74,6 +83,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let user_repo = MongoUserRepository::new(db.clone());
|
||||
let task_repo = MongoTaskRepository::new(db.clone());
|
||||
let dev_repo = MongoDeveloperRepository::new(db.clone());
|
||||
let audit_repo = MongoAuditRepository::new(db.clone());
|
||||
|
||||
// Auto-provision initial administrator if users collection is empty
|
||||
let users_count = db.collection::<crate::auth::models::User>("users")
|
||||
@@ -118,15 +128,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
user_repo,
|
||||
task_repo,
|
||||
dev_repo,
|
||||
audit_repo,
|
||||
};
|
||||
|
||||
// 6. Build and merge routers by use-case
|
||||
let app = Router::new()
|
||||
.merge(main_view::router())
|
||||
.merge(components::router())
|
||||
.merge(docs::router())
|
||||
.merge(auth::router())
|
||||
.merge(tasks::router())
|
||||
.merge(developers::router())
|
||||
.merge(audit::router())
|
||||
.with_state(state);
|
||||
|
||||
// 7. Bind address and run server
|
||||
@@ -137,7 +149,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Listening on http://{}", host_addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(host_addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+71
-1
@@ -4,6 +4,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use crate::audit::AuditLogger;
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::Deserialize;
|
||||
use crate::common::errors::AppError;
|
||||
@@ -49,6 +50,7 @@ struct DashboardTemplate {
|
||||
pub async fn get_dashboard(
|
||||
State(task_repo): State<MongoTaskRepository>,
|
||||
State(dev_repo): State<MongoDeveloperRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(user) = user_opt else {
|
||||
@@ -70,6 +72,14 @@ pub async fn get_dashboard(
|
||||
});
|
||||
}
|
||||
|
||||
audit.log(
|
||||
"View",
|
||||
"Task",
|
||||
None,
|
||||
Some("Viewed task dashboard".to_string()),
|
||||
None
|
||||
).await;
|
||||
|
||||
Ok(HtmlTemplate(DashboardTemplate {
|
||||
username: user.username,
|
||||
authenticated: true,
|
||||
@@ -87,6 +97,7 @@ pub struct CreateTaskForm {
|
||||
|
||||
pub async fn post_create_task(
|
||||
State(task_repo): State<MongoTaskRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Form(payload): Form<CreateTaskForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -108,15 +119,31 @@ pub async fn post_create_task(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
task_repo
|
||||
let new_task = task_repo
|
||||
.create(&user.user_id, title, description, assigned_to)
|
||||
.await?;
|
||||
|
||||
let payload_val = serde_json::json!({
|
||||
"title": new_task.title,
|
||||
"description": new_task.description,
|
||||
"assigned_to": new_task.assigned_to.map(|id| id.to_hex()),
|
||||
"is_completed": new_task.is_completed,
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Create",
|
||||
"Task",
|
||||
new_task.id,
|
||||
Some(format!("Created task: {}", title)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
|
||||
Ok(Redirect::to("/tasks").into_response())
|
||||
}
|
||||
|
||||
pub async fn post_complete_task(
|
||||
State(task_repo): State<MongoTaskRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(task_id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -127,13 +154,37 @@ pub async fn post_complete_task(
|
||||
let task_id = ObjectId::parse_str(&task_id_str)
|
||||
.map_err(|_| AppError::BadRequest("Invalid task identifier".to_string()))?;
|
||||
|
||||
let task_before = task_repo.find_by_id(&task_id, &user.user_id).await?;
|
||||
|
||||
task_repo.mark_completed(&task_id, &user.user_id).await?;
|
||||
|
||||
if let Some(tb) = task_before {
|
||||
let payload_val = serde_json::json!({
|
||||
"before": {
|
||||
"title": tb.title,
|
||||
"is_completed": tb.is_completed,
|
||||
},
|
||||
"after": {
|
||||
"title": tb.title,
|
||||
"is_completed": true,
|
||||
}
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Update",
|
||||
"Task",
|
||||
Some(task_id),
|
||||
Some(format!("Marked task '{}' as completed", tb.title)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
}
|
||||
|
||||
Ok(Redirect::to("/tasks").into_response())
|
||||
}
|
||||
|
||||
pub async fn post_delete_task(
|
||||
State(task_repo): State<MongoTaskRepository>,
|
||||
audit: AuditLogger,
|
||||
user_opt: Option<AuthenticatedUser>,
|
||||
Path(task_id_str): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -144,7 +195,26 @@ pub async fn post_delete_task(
|
||||
let task_id = ObjectId::parse_str(&task_id_str)
|
||||
.map_err(|_| AppError::BadRequest("Invalid task identifier".to_string()))?;
|
||||
|
||||
let task_to_delete = task_repo.find_by_id(&task_id, &user.user_id).await?;
|
||||
|
||||
task_repo.delete(&task_id, &user.user_id).await?;
|
||||
|
||||
if let Some(td) = task_to_delete {
|
||||
let payload_val = serde_json::json!({
|
||||
"title": td.title,
|
||||
"description": td.description,
|
||||
"assigned_to": td.assigned_to.map(|id| id.to_hex()),
|
||||
"is_completed": td.is_completed,
|
||||
});
|
||||
|
||||
audit.log(
|
||||
"Delete",
|
||||
"Task",
|
||||
Some(task_id),
|
||||
Some(format!("Deleted task: {}", td.title)),
|
||||
Some(payload_val)
|
||||
).await;
|
||||
}
|
||||
|
||||
Ok(Redirect::to("/tasks").into_response())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ where
|
||||
Config: axum::extract::FromRef<S>,
|
||||
MongoTaskRepository: axum::extract::FromRef<S>,
|
||||
crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef<S>,
|
||||
mongodb::Database: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 find_by_id(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<Option<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>;
|
||||
@@ -28,6 +29,13 @@ impl MongoTaskRepository {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TaskRepository for MongoTaskRepository {
|
||||
async fn find_by_id(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<Option<Task>, AppError> {
|
||||
let collection = self.db.collection::<Task>("tasks");
|
||||
let filter = doc! { "_id": task_id, "user_id": user_id };
|
||||
let task = collection.find_one(filter).await?;
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
async fn find_all_by_user(&self, user_id: &ObjectId) -> Result<Vec<Task>, AppError> {
|
||||
let collection = self.db.collection::<Task>("tasks");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user