Compare commits
3 Commits
1adc4806fa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d5bad87bc | |||
| 829fc3b008 | |||
| 215d6739d1 |
@@ -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;
|
||||
@@ -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) ==='
|
||||
\i 009_settings.sql
|
||||
|
||||
\echo '=== Running 010_chat_notifications.sql (Chat Message Notifications) ==='
|
||||
\i 010_chat_notifications.sql
|
||||
|
||||
\echo '=== Migration Complete ==='
|
||||
COMMIT;
|
||||
|
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 |
+16
-1
@@ -116,13 +116,28 @@ export async function POST(
|
||||
)
|
||||
|
||||
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({
|
||||
message: {
|
||||
id: msg.id,
|
||||
conversationId: id,
|
||||
senderId: user.id,
|
||||
senderName: `${user.firstName} ${user.lastName}`,
|
||||
senderName,
|
||||
senderAvatar: user.avatar,
|
||||
content: content.trim(),
|
||||
timestamp: formatTime(new Date(msg.created_at)),
|
||||
+6
@@ -19,6 +19,12 @@ export async function POST(
|
||||
[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 })
|
||||
} catch (error) {
|
||||
console.error("Mark read error:", error)
|
||||
+3
-1
@@ -8,7 +8,7 @@ export async function GET() {
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
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
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -24,6 +24,8 @@ export async function GET() {
|
||||
link: r.link,
|
||||
read: r.is_read,
|
||||
timestamp: r.created_at,
|
||||
contextId: r.context_id,
|
||||
contextType: r.context_type,
|
||||
}))
|
||||
|
||||
const unreadResult = await query(
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user