Compare commits

..

3 Commits

Author SHA1 Message Date
caitlin 8d5bad87bc Push all current state 2026-06-23 11:21:24 +02:00
caitlin 829fc3b008 Add frontend_old as regular files 2026-06-22 15:50:36 +02:00
caitlin 215d6739d1 Initial commit - current CRM state 2026-06-22 15:43:25 +02:00
964 changed files with 3770 additions and 420036 deletions
+6
View File
@@ -0,0 +1,6 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm
JWT_SECRET=dev-secret-key-do-not-use-in-production
JWT_EXPIRY_HOURS=24
HOST=0.0.0.0
PORT=3001
RUST_LOG=crm_api=debug,tower_http=debug
+6
View File
@@ -0,0 +1,6 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm
JWT_SECRET=super-secret-key-change-in-production
JWT_EXPIRY_HOURS=24
HOST=0.0.0.0
PORT=3001
RUST_LOG=crm_api=debug,tower_http=debug
+2814
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "crm-api"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json", "rust_decimal", "ipnetwork"] }
dotenvy = "0.15"
jsonwebtoken = "9"
uuid = { version = "1", features = ["v4", "serde"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower = "0.4"
thiserror = "1"
bcrypt = "0.15"
sha2 = "0.10"
hex = "0.4"
rust_decimal = { version = "1", features = ["serde"] }
+53
View File
@@ -0,0 +1,53 @@
use axum::http::StatusCode;
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: Uuid,
pub username: String,
pub role: String,
pub exp: usize,
pub iat: usize,
}
pub fn create_token(
user_id: Uuid,
username: &str,
role: &str,
secret: &str,
expiry_hours: i64,
) -> Result<String, StatusCode> {
let now = Utc::now();
let exp = now
.checked_add_signed(Duration::hours(expiry_hours))
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?
.timestamp() as usize;
let claims = Claims {
sub: user_id,
username: username.to_string(),
role: role.to_string(),
exp,
iat: now.timestamp() as usize,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_ref()),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
pub fn validate_token(token: &str, secret: &str) -> Result<Claims, StatusCode> {
decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::default(),
)
.map(|data| data.claims)
.map_err(|_| StatusCode::UNAUTHORIZED)
}
+31
View File
@@ -0,0 +1,31 @@
use std::env;
#[derive(Clone)]
pub struct AppConfig {
pub database_url: String,
pub jwt_secret: String,
pub jwt_expiry_hours: i64,
pub host: String,
pub port: u16,
}
impl AppConfig {
pub fn from_env() -> Self {
Self {
database_url: env::var("DATABASE_URL")
.expect("DATABASE_URL must be set"),
jwt_secret: env::var("JWT_SECRET")
.expect("JWT_SECRET must be set"),
jwt_expiry_hours: env::var("JWT_EXPIRY_HOURS")
.unwrap_or_else(|_| "24".to_string())
.parse()
.expect("JWT_EXPIRY_HOURS must be a valid integer"),
host: env::var("HOST")
.unwrap_or_else(|_| "0.0.0.0".to_string()),
port: env::var("PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.expect("PORT must be a valid u16"),
}
}
}
+17
View File
@@ -0,0 +1,17 @@
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn init_db_pool(database_url: &str) -> PgPool {
PgPoolOptions::new()
.max_connections(20)
.connect(database_url)
.await
.expect("Failed to connect to PostgreSQL database")
}
pub async fn verify_connection(pool: &PgPool) -> bool {
sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(pool)
.await
.is_ok()
}
+36
View File
@@ -0,0 +1,36 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct ApiResponse<T: Serialize> {
pub status: String,
pub message: String,
pub data: Option<T>,
pub error: Option<String>,
}
impl<T: Serialize> ApiResponse<T> {
pub fn success(data: T, message: &str) -> Self {
Self {
status: "success".to_string(),
message: message.to_string(),
data: Some(data),
error: None,
}
}
pub fn error(msg: &str) -> Self {
Self {
status: "error".to_string(),
message: String::new(),
data: None,
error: Some(msg.to_string()),
}
}
}
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
pub service: String,
pub database: String,
}
+25
View File
@@ -0,0 +1,25 @@
use axum::extract::State;
use axum::Json;
use serde_json::Value;
use crate::database::verify_connection;
use crate::dto::{ApiResponse, HealthResponse};
use crate::AppState;
pub async fn health_check(State(state): State<AppState>) -> Json<Value> {
let db_ok = verify_connection(&state.pool).await;
let response = HealthResponse {
status: "ok".to_string(),
service: "crm-api".to_string(),
database: if db_ok {
"connected".to_string()
} else {
"disconnected".to_string()
},
};
Json(serde_json::to_value(ApiResponse::success(
response,
"Service is healthy",
))
.unwrap())
}
+56
View File
@@ -0,0 +1,56 @@
mod auth;
mod config;
mod database;
mod dto;
mod handlers;
mod middleware;
mod models;
mod repositories;
mod routes;
mod services;
use std::net::SocketAddr;
use axum::Router;
use sqlx::PgPool;
use tower_http::trace::TraceLayer;
use crate::config::AppConfig;
use crate::routes::create_router;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: AppConfig,
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
let config = AppConfig::from_env();
let pool = database::init_db_pool(&config.database_url).await;
tracing::info!("Database connection established");
let state = AppState {
pool,
config: config.clone(),
};
let app: Router = create_router(state)
.layer(TraceLayer::new_for_http())
.layer(middleware::cors_layer());
let addr: SocketAddr = format!("{}:{}", config.host, config.port)
.parse()
.expect("Invalid host or port");
tracing::info!("CRM API server starting on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
+29
View File
@@ -0,0 +1,29 @@
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde_json::json;
use tower_http::cors::{Any, CorsLayer};
pub fn cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
}
pub struct AppError {
pub status: StatusCode,
pub message: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let body = json!({
"status": "error",
"message": self.message,
"data": null,
"error": self.message,
});
(self.status, Json(body)).into_response()
}
}
+400
View File
@@ -0,0 +1,400 @@
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct User {
pub id: Uuid,
pub username: String,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub first_name: String,
pub last_name: String,
pub phone: Option<String>,
pub is_active: bool,
pub is_locked: bool,
pub lock_reason: Option<String>,
pub password_change_required: bool,
pub email_verified_at: Option<DateTime<Utc>>,
pub last_login_at: Option<DateTime<Utc>>,
pub failed_login_attempts: i32,
pub locked_until: Option<DateTime<Utc>>,
pub preferences: Option<serde_json::Value>,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Role {
pub id: Uuid,
pub name: String,
pub display_name: Option<String>,
pub description: Option<String>,
pub hierarchy_level: i32,
pub is_system: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Permission {
pub id: Uuid,
pub resource: String,
pub action: String,
pub description: Option<String>,
pub category: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct UserRole {
pub user_id: Uuid,
pub role_id: Uuid,
pub assigned_by: Option<Uuid>,
pub assigned_at: DateTime<Utc>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Session {
pub id: Uuid,
pub user_id: Uuid,
#[serde(skip_serializing)]
pub token_hash: String,
pub ip_address: Option<sqlx::types::ipnetwork::IpNetwork>,
pub user_agent: Option<String>,
pub is_active: bool,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Customer {
pub id: Uuid,
pub customer_type: String,
pub status_id: Uuid,
pub owner_id: Option<Uuid>,
pub source: Option<String>,
pub notes: Option<String>,
pub tags: Option<Vec<String>>,
pub score: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct CustomerStatus {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub color: Option<String>,
pub sort_order: i32,
pub is_default: bool,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct IndividualCustomer {
pub customer_id: Uuid,
pub first_name: String,
pub last_name: String,
pub middle_name: Option<String>,
pub date_of_birth: Option<chrono::NaiveDate>,
pub gender: Option<String>,
pub job_title: Option<String>,
pub company_name: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct CompanyCustomer {
pub customer_id: Uuid,
pub company_name: String,
pub registration_number: Option<String>,
pub tax_id: Option<String>,
pub website: Option<String>,
pub industry: Option<String>,
pub company_size: Option<String>,
pub annual_revenue: Option<rust_decimal::Decimal>,
pub founded_date: Option<chrono::NaiveDate>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct ContactInformation {
pub id: Uuid,
pub customer_id: Uuid,
pub r#type: String,
pub value: String,
pub label: Option<String>,
pub is_primary: bool,
pub is_verified: bool,
pub verified_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Address {
pub id: Uuid,
pub customer_id: Uuid,
pub address_type: String,
pub address_line1: String,
pub address_line2: Option<String>,
pub city: String,
pub state: Option<String>,
pub postal_code: Option<String>,
pub country: String,
pub is_primary: bool,
pub latitude: Option<rust_decimal::Decimal>,
pub longitude: Option<rust_decimal::Decimal>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Lead {
pub id: Uuid,
pub source_id: Option<Uuid>,
pub stage_id: Uuid,
pub assigned_to: Option<Uuid>,
pub company_name: Option<String>,
pub contact_name: String,
pub email: Option<String>,
pub phone: Option<String>,
pub job_title: Option<String>,
pub budget_range: Option<String>,
pub interest_level: Option<String>,
pub notes: Option<String>,
pub score: i32,
pub converted_customer_id: Option<Uuid>,
pub converted_at: Option<DateTime<Utc>>,
pub converted_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct LeadSource {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct LeadStage {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub sort_order: i32,
pub probability: i32,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Opportunity {
pub id: Uuid,
pub customer_id: Uuid,
pub lead_id: Option<Uuid>,
pub stage_id: Uuid,
pub owner_id: Uuid,
pub name: String,
pub description: Option<String>,
pub estimated_revenue: Option<rust_decimal::Decimal>,
pub probability: Option<i32>,
pub expected_close_date: Option<chrono::NaiveDate>,
pub actual_close_date: Option<chrono::NaiveDate>,
pub loss_reason: Option<String>,
pub is_won: Option<bool>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct DealStage {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub sort_order: i32,
pub probability: i32,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Product {
pub id: Uuid,
pub category_id: Option<Uuid>,
pub name: String,
pub description: Option<String>,
pub sku: Option<String>,
pub unit_price: rust_decimal::Decimal,
pub cost_price: Option<rust_decimal::Decimal>,
pub currency: String,
pub is_active: bool,
pub is_service: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct ProductCategory {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub parent_id: Option<Uuid>,
pub is_active: bool,
pub sort_order: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Communication {
pub id: Uuid,
pub customer_id: Uuid,
pub opportunity_id: Option<Uuid>,
pub type_id: Uuid,
pub subject: Option<String>,
pub body: Option<String>,
pub direction: String,
pub created_by: Uuid,
pub started_at: Option<DateTime<Utc>>,
pub duration_minutes: Option<i32>,
pub outcome: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct CommunicationType {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Task {
pub id: Uuid,
pub customer_id: Option<Uuid>,
pub opportunity_id: Option<Uuid>,
pub communication_id: Option<Uuid>,
pub assigned_to: Option<Uuid>,
pub assigned_by: Option<Uuid>,
pub title: String,
pub description: Option<String>,
pub priority_id: Option<Uuid>,
pub status: String,
pub due_date: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub reminder_at: Option<DateTime<Utc>>,
pub reminder_sent: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct TaskPriority {
pub id: Uuid,
pub name: String,
pub color: Option<String>,
pub sort_order: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Invoice {
pub id: Uuid,
pub customer_id: Uuid,
pub opportunity_id: Option<Uuid>,
pub status_id: Uuid,
pub invoice_number: String,
pub subtotal: rust_decimal::Decimal,
pub tax_percent: Option<rust_decimal::Decimal>,
pub tax_amount: Option<rust_decimal::Decimal>,
pub discount_percent: Option<rust_decimal::Decimal>,
pub discount_amount: Option<rust_decimal::Decimal>,
pub total_amount: rust_decimal::Decimal,
pub currency: String,
pub issued_date: chrono::NaiveDate,
pub due_date: chrono::NaiveDate,
pub paid_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct InvoiceStatus {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub color: Option<String>,
pub sort_order: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct Payment {
pub id: Uuid,
pub invoice_id: Uuid,
pub amount: rust_decimal::Decimal,
pub payment_method: Option<String>,
pub transaction_id: Option<String>,
pub status: String,
pub paid_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
pub created_by: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow, serde::Serialize, serde::Deserialize)]
pub struct AuditLog {
pub id: Uuid,
pub table_name: String,
pub record_id: Uuid,
pub action: String,
pub old_data: Option<serde_json::Value>,
pub new_data: Option<serde_json::Value>,
pub changed_by: Option<Uuid>,
pub changed_at: DateTime<Utc>,
pub ip_address: Option<sqlx::types::ipnetwork::IpNetwork>,
pub user_agent: Option<String>,
pub session_id: Option<Uuid>,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod user_repo;
pub mod session_repo;
+74
View File
@@ -0,0 +1,74 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::Session;
pub async fn create_session(
pool: &PgPool,
user_id: Uuid,
token_hash: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
expires_at: &chrono::DateTime<chrono::Utc>,
) -> Result<Session, sqlx::Error> {
let ip_network = ip_address
.and_then(|ip| ip.parse::<sqlx::types::ipnetwork::IpNetwork>().ok());
sqlx::query_as::<_, Session>(
r#"
INSERT INTO sessions (user_id, token_hash, ip_address, user_agent, is_active, expires_at)
VALUES ($1, $2, $3, $4, TRUE, $5)
RETURNING *
"#,
)
.bind(user_id)
.bind(token_hash)
.bind(ip_network)
.bind(user_agent)
.bind(expires_at)
.fetch_one(pool)
.await
}
pub async fn invalidate_session(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE sessions
SET is_active = FALSE
WHERE token_hash = $1
"#,
)
.bind(token_hash)
.execute(pool)
.await?;
Ok(())
}
pub async fn invalidate_user_sessions(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE sessions
SET is_active = FALSE
WHERE user_id = $1 AND is_active = TRUE
"#,
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn find_active_session(
pool: &PgPool,
token_hash: &str,
) -> Result<Option<Session>, sqlx::Error> {
sqlx::query_as::<_, Session>(
r#"
SELECT * FROM sessions
WHERE token_hash = $1 AND is_active = TRUE AND expires_at > NOW()
"#,
)
.bind(token_hash)
.fetch_optional(pool)
.await
}
+68
View File
@@ -0,0 +1,68 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::User;
pub async fn find_by_username(pool: &PgPool, username: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT * FROM users
WHERE username = $1 AND deleted_at IS NULL
"#,
)
.bind(username)
.fetch_optional(pool)
.await
}
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT * FROM users
WHERE email = $1 AND deleted_at IS NULL
"#,
)
.bind(email)
.fetch_optional(pool)
.await
}
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT * FROM users
WHERE id = $1 AND deleted_at IS NULL
"#,
)
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn update_last_login(pool: &PgPool, user_id: Uuid, ip: &str) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE users
SET last_login_at = NOW(), failed_login_attempts = 0
WHERE id = $1
"#,
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn increment_failed_login(pool: &PgPool, username: &str) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE users
SET failed_login_attempts = failed_login_attempts + 1
WHERE username = $1 AND deleted_at IS NULL
"#,
)
.bind(username)
.execute(pool)
.await?;
Ok(())
}
+10
View File
@@ -0,0 +1,10 @@
use axum::{routing::get, Router};
use crate::handlers;
use crate::AppState;
pub fn create_router(state: AppState) -> Router {
Router::new()
.route("/api/health", get(handlers::health_check))
.with_state(state)
}
+86
View File
@@ -0,0 +1,86 @@
use axum::http::StatusCode;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::{create_token, Claims};
use crate::repositories;
use crate::repositories::session_repo;
pub struct LoginResponse {
pub token: String,
pub user_id: Uuid,
pub username: String,
pub role: String,
}
pub async fn login(
pool: &PgPool,
username: &str,
password: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
jwt_secret: &str,
jwt_expiry_hours: i64,
) -> Result<LoginResponse, StatusCode> {
let user = repositories::user_repo::find_by_username(pool, username)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
if !user.is_active {
return Err(StatusCode::FORBIDDEN);
}
if user.is_locked {
return Err(StatusCode::LOCKED);
}
let password_valid = bcrypt::verify(password, &user.password_hash)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !password_valid {
let _ = repositories::user_repo::increment_failed_login(pool, username).await;
return Err(StatusCode::UNAUTHORIZED);
}
let _ = repositories::user_repo::update_last_login(pool, user.id, ip_address.unwrap_or("")).await;
let role = "USER";
let token = create_token(user.id, &user.username, role, jwt_secret, jwt_expiry_hours)?;
let token_hash = hex::encode(Sha256::digest(token.as_bytes()));
let expires_at = chrono::Utc::now()
.checked_add_signed(chrono::Duration::hours(jwt_expiry_hours))
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
let _ = session_repo::create_session(
pool,
user.id,
&token_hash,
ip_address,
user_agent,
&expires_at,
)
.await;
Ok(LoginResponse {
token,
user_id: user.id,
username: user.username,
role: role.to_string(),
})
}
pub async fn logout(pool: &PgPool, token: &str) -> Result<(), StatusCode> {
let token_hash = hex::encode(Sha256::digest(token.as_bytes()));
session_repo::invalidate_session(pool, &token_hash)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
pub fn validate_token(token: &str, jwt_secret: &str) -> Result<Claims, StatusCode> {
crate::auth::validate_token(token, jwt_secret)
}
+1
View File
@@ -0,0 +1 @@
pub mod auth_service;
View File
@@ -0,0 +1,4 @@
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_id UUID;
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id) WHERE context_type IS NOT NULL;
@@ -34,5 +34,8 @@ BEGIN;
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ===' \echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
\i 009_settings.sql \i 009_settings.sql
\echo '=== Running 010_chat_notifications.sql (Chat Message Notifications) ==='
\i 010_chat_notifications.sql
\echo '=== Migration Complete ===' \echo '=== Migration Complete ==='
COMMIT; COMMIT;
View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -116,13 +116,28 @@ export async function POST(
) )
const msg = result.rows[0] const msg = result.rows[0]
const senderName = `${user.firstName} ${user.lastName}`
const otherResult = await query(
`SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
[id, user.id],
)
for (const row of otherResult.rows) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, 'chat_message', 'New Message', $2, '/chats', $3, 'conversation')`,
[row.user_id, `${senderName} sent a message`, id],
)
}
return NextResponse.json({ return NextResponse.json({
message: { message: {
id: msg.id, id: msg.id,
conversationId: id, conversationId: id,
senderId: user.id, senderId: user.id,
senderName: `${user.firstName} ${user.lastName}`, senderName,
senderAvatar: user.avatar, senderAvatar: user.avatar,
content: content.trim(), content: content.trim(),
timestamp: formatTime(new Date(msg.created_at)), timestamp: formatTime(new Date(msg.created_at)),
@@ -19,6 +19,12 @@ export async function POST(
[id, user.id], [id, user.id],
) )
await query(
`UPDATE notifications SET is_read = TRUE
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
[user.id, id],
)
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Mark read error:", error) console.error("Mark read error:", error)
@@ -8,7 +8,7 @@ export async function GET() {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query( const result = await query(
`SELECT id, type, title, description, link, is_read, created_at `SELECT id, type, title, description, link, is_read, created_at, context_id, context_type
FROM notifications FROM notifications
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -24,6 +24,8 @@ export async function GET() {
link: r.link, link: r.link,
read: r.is_read, read: r.is_read,
timestamp: r.created_at, timestamp: r.created_at,
contextId: r.context_id,
contextType: r.context_type,
})) }))
const unreadResult = await query( const unreadResult = await query(

Some files were not shown because too many files have changed in this diff Show More