Initial commit - current CRM state
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+2814
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod user_repo;
|
||||||
|
pub mod session_repo;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod auth_service;
|
||||||
Submodule
+1
Submodule frontend_old added at f45f9d61da
Reference in New Issue
Block a user