Compare commits
3 Commits
7a42e3c41c
..
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;
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- CRM Database — Full Migration Runner
|
|
||||||
-- ============================================================================
|
|
||||||
-- Usage: psql -U postgres -d crm -f run_all.sql
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
|
|
||||||
\i 001_schema.sql
|
|
||||||
|
|
||||||
\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ==='
|
|
||||||
\i 002_seed.sql
|
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
|
||||||
\echo ''
|
|
||||||
\echo 'Test accounts created:'
|
|
||||||
\echo ' superadmin_demo / SuperAdmin@2026'
|
|
||||||
\echo ' admin_demo / AdminAccess@2026'
|
|
||||||
\echo ' sales_demo / SalesAccess@2026'
|
|
||||||
\echo ' dev_demo / DevTesting@2026'
|
|
||||||
@@ -34,9 +34,28 @@ yarn-error.log*
|
|||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
# runtime data
|
||||||
|
data/
|
||||||
|
data/ai/
|
||||||
|
data/ai/jobs.jsonl
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
error-log
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# python
|
||||||
|
browser-use-service/venv/
|
||||||
|
browser-use-service/__pycache__/
|
||||||
|
browser-use-service/.env
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
.git
|
||||||
|
.git_bak
|
||||||
|
node_modules
|
||||||
|
target
|
||||||
@@ -27,6 +27,14 @@ To learn more about Next.js, take a look at the following resources:
|
|||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
- Jose
|
||||||
|
- Node.js
|
||||||
|
- Add emoji pakages
|
||||||
|
- Install postgres for databases
|
||||||
|
- Typescript
|
||||||
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
## Deploy on Vercel
|
## Deploy on Vercel
|
||||||
Binary file not shown.
@@ -0,0 +1,410 @@
|
|||||||
|
import os, json, asyncio, re, shutil, sqlite3, traceback, urllib.parse, random, time, logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import uvicorn
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
PORT = int(os.getenv("PORT", "3008"))
|
||||||
|
|
||||||
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
|
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
||||||
|
|
||||||
|
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
||||||
|
FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite') if FX_PROFILE else ''
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
}
|
||||||
|
|
||||||
|
last_scrape_time: datetime | None = None
|
||||||
|
|
||||||
|
STRICT_KEYWORDS = [
|
||||||
|
"website", "web design", "web develop", "web dev",
|
||||||
|
"build my website", "build a website", "create a website",
|
||||||
|
"need web", "looking for web", "new website",
|
||||||
|
"landing page", "wordpress",
|
||||||
|
"need a website", "my website", "business website",
|
||||||
|
"need a designer", "help with my website",
|
||||||
|
"redesign", "update my website",
|
||||||
|
]
|
||||||
|
|
||||||
|
BROAD_KEYWORDS = STRICT_KEYWORDS + [
|
||||||
|
"looking for", "need a", "need an", "looking to",
|
||||||
|
"help me build", "create my", "build me",
|
||||||
|
"design my", "make me a", "would like",
|
||||||
|
"need someone", "hire a", "looking to hire",
|
||||||
|
"want someone", "need help with",
|
||||||
|
]
|
||||||
|
|
||||||
|
def kw_match_strict(text: str) -> bool:
|
||||||
|
t = text.lower()
|
||||||
|
return any(kw in t for kw in STRICT_KEYWORDS)
|
||||||
|
|
||||||
|
def kw_match(text: str) -> bool:
|
||||||
|
t = text.lower()
|
||||||
|
return any(kw in t for kw in BROAD_KEYWORDS)
|
||||||
|
|
||||||
|
FB_SEARCHES = [
|
||||||
|
"looking for web developer",
|
||||||
|
"need a website designed",
|
||||||
|
"need website built South Africa",
|
||||||
|
"need someone to build my website",
|
||||||
|
"need web designer",
|
||||||
|
"looking for someone to create website",
|
||||||
|
"need ecommerce website built",
|
||||||
|
"want to hire web developer",
|
||||||
|
"website quote please",
|
||||||
|
"need wordpress website",
|
||||||
|
"I need a website for my business",
|
||||||
|
"need a site for my business",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_fb_cookies():
|
||||||
|
if not FX_COOKIE_DB or not os.path.exists(FX_COOKIE_DB):
|
||||||
|
logger.warning("FX_COOKIE_DB not found or FX_PROFILE not set")
|
||||||
|
return []
|
||||||
|
tmp = os.path.join(os.path.dirname(FX_COOKIE_DB), f'cookies_tmp_{os.getpid()}.sqlite')
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
shutil.copy2(FX_COOKIE_DB, tmp)
|
||||||
|
break
|
||||||
|
except PermissionError:
|
||||||
|
if attempt < 2:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
else:
|
||||||
|
logger.error("Failed to copy cookie DB after 3 attempts")
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(tmp)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'")
|
||||||
|
rows = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
os.remove(tmp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return [{
|
||||||
|
"name": name, "value": value,
|
||||||
|
"domain": host if host.startswith('.') else f'.{host}',
|
||||||
|
"path": path,
|
||||||
|
"httpOnly": True, "secure": True, "sameSite": "Lax",
|
||||||
|
} for name, value, host, path in rows]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cookie DB read error: %s", e)
|
||||||
|
try:
|
||||||
|
os.remove(tmp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||||
|
|
||||||
|
def _parse_fb_date(block: list[str]) -> str:
|
||||||
|
for l in block:
|
||||||
|
l = l.strip()
|
||||||
|
m = re.match(r'(\d+)\s*(h|hr|hrs|hour|hours)\s*ago', l)
|
||||||
|
if m:
|
||||||
|
hours = int(m.group(1))
|
||||||
|
d = datetime.now() - timedelta(hours=hours)
|
||||||
|
return d.strftime('%Y-%m-%d')
|
||||||
|
if l.lower() == 'yesterday':
|
||||||
|
d = datetime.now() - timedelta(days=1)
|
||||||
|
return d.strftime('%Y-%m-%d')
|
||||||
|
low = l.lower()
|
||||||
|
if low in WEEKDAY_ORDER:
|
||||||
|
day_idx = WEEKDAY_ORDER.index(low)
|
||||||
|
today_idx = datetime.now().weekday()
|
||||||
|
days_ago = (today_idx - day_idx) % 7
|
||||||
|
d = datetime.now() - timedelta(days=days_ago)
|
||||||
|
return d.strftime('%Y-%m-%d')
|
||||||
|
for fmt in ['%Y-%m-%d', '%d %B %Y', '%B %d %Y', '%d %b %Y', '%b %d %Y', '%d %b', '%b %d']:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(l, fmt)
|
||||||
|
return dt.strftime('%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return datetime.now().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
||||||
|
lines = [l.strip() for l in raw.split('\n')]
|
||||||
|
blocks = []
|
||||||
|
cur = []
|
||||||
|
fb_run = 0
|
||||||
|
for l in lines:
|
||||||
|
if 'facebook' in l.lower():
|
||||||
|
fb_run += 1
|
||||||
|
if cur and fb_run >= 2:
|
||||||
|
blocks.append(cur)
|
||||||
|
cur = []
|
||||||
|
else:
|
||||||
|
fb_run = 0
|
||||||
|
if l and len(l) >= 15:
|
||||||
|
words = re.findall(r'[A-Za-z]{2,}', l)
|
||||||
|
if len(words) >= 2:
|
||||||
|
cur.append(l)
|
||||||
|
if cur:
|
||||||
|
blocks.append(cur)
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
seen_texts = set()
|
||||||
|
for block in blocks:
|
||||||
|
if not block:
|
||||||
|
continue
|
||||||
|
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
|
||||||
|
if not kw_indices:
|
||||||
|
continue
|
||||||
|
i = kw_indices[0]
|
||||||
|
start = max(0, i - 2)
|
||||||
|
end = min(len(block), i + 5)
|
||||||
|
snippet = ' '.join(block[start:end])
|
||||||
|
if len(snippet) < 40:
|
||||||
|
continue
|
||||||
|
dekey = snippet[:80]
|
||||||
|
if dekey in seen_texts:
|
||||||
|
continue
|
||||||
|
seen_texts.add(dekey)
|
||||||
|
posts.append({
|
||||||
|
"title": snippet[:300],
|
||||||
|
"content": snippet[:1000],
|
||||||
|
"author": block[start] if start < i else '',
|
||||||
|
"url": url,
|
||||||
|
"source": "facebook",
|
||||||
|
"date": _parse_fb_date(block),
|
||||||
|
})
|
||||||
|
return posts
|
||||||
|
|
||||||
|
async def search_facebook(page, query: str) -> list[dict]:
|
||||||
|
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
||||||
|
try:
|
||||||
|
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||||
|
await page.wait_for_timeout(6000)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Facebook search navigation failed for '%s': %s", query, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await page.evaluate('document.body.innerText')
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to evaluate page text: %s", e)
|
||||||
|
return []
|
||||||
|
return _extract_posts_from_text(raw, url)
|
||||||
|
|
||||||
|
async def scrape_facebook() -> list[dict]:
|
||||||
|
all_posts = []
|
||||||
|
fb_cookies = await get_fb_cookies()
|
||||||
|
if not fb_cookies:
|
||||||
|
logger.warning("No Facebook cookies available")
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
async with async_playwright() as pw:
|
||||||
|
browser = await pw.chromium.launch(headless=True)
|
||||||
|
context = await browser.new_context(
|
||||||
|
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
||||||
|
viewport={'width': 1280, 'height': 800},
|
||||||
|
)
|
||||||
|
for c in fb_cookies:
|
||||||
|
try:
|
||||||
|
await context.add_cookies([c])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
|
||||||
|
|
||||||
|
page = await context.new_page()
|
||||||
|
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
||||||
|
await page.wait_for_timeout(5000)
|
||||||
|
|
||||||
|
url = page.url
|
||||||
|
if '/login' in url.lower():
|
||||||
|
logger.warning("Facebook login page detected — cookies may be expired")
|
||||||
|
return []
|
||||||
|
|
||||||
|
for query in FB_SEARCHES[:6]:
|
||||||
|
try:
|
||||||
|
posts = await search_facebook(page, query)
|
||||||
|
all_posts.extend(posts)
|
||||||
|
delay = random.uniform(3, 7)
|
||||||
|
await page.wait_for_timeout(int(delay * 1000))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Facebook search '%s' failed: %s", query, e)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Facebook scrape failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
deduped = []
|
||||||
|
for p in all_posts:
|
||||||
|
key = p.get('content', '')[:100]
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(p)
|
||||||
|
return deduped[:20]
|
||||||
|
|
||||||
|
async def ask_ollama(prompt: str) -> str:
|
||||||
|
async with httpx.AsyncClient(timeout=120) as c:
|
||||||
|
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
|
||||||
|
"model": CLASSIFY_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You classify forum posts. Return only valid JSON."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.05, "num_predict": 1024},
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return data["message"]["content"]
|
||||||
|
|
||||||
|
async def scrape_warriorforum() -> list[dict]:
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(headers=HEADERS, timeout=15, follow_redirects=True) as c:
|
||||||
|
r = await c.get('https://www.warriorforum.com/wanted-members-looking-hire-you/')
|
||||||
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
for row in soup.find_all('td', class_='FlexTable-item--title'):
|
||||||
|
a = row.find('a', href=True)
|
||||||
|
if not a:
|
||||||
|
continue
|
||||||
|
href = a['href']
|
||||||
|
title = a.text.strip()
|
||||||
|
if not title or len(title) < 15 or not kw_match_strict(title):
|
||||||
|
continue
|
||||||
|
author_div = row.find('div', class_='FlexTable-item-author')
|
||||||
|
author = author_div.get_text(strip=True).lstrip('by') if author_div else ''
|
||||||
|
date_str = ''
|
||||||
|
date_div = row.find('div', class_=lambda c: c and 'media--available' in c)
|
||||||
|
if date_div:
|
||||||
|
txt = date_div.get_text(strip=True)
|
||||||
|
m = re.search(r'(\d{10})', txt)
|
||||||
|
if m:
|
||||||
|
date_str = datetime.utcfromtimestamp(int(m.group(1))).strftime('%Y-%m-%d')
|
||||||
|
if not href.startswith('http'):
|
||||||
|
href = 'https://www.warriorforum.com' + href
|
||||||
|
results.append({
|
||||||
|
"title": title, "url": href,
|
||||||
|
"author": author,
|
||||||
|
"content": title[:300],
|
||||||
|
"source": "warriorforum",
|
||||||
|
"date": date_str
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.error("WarriorForum scrape failed", exc_info=True)
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def classify_leads(results: list[dict]) -> list[dict]:
|
||||||
|
if not results:
|
||||||
|
return []
|
||||||
|
briefs = [r["title"][:200] for r in results]
|
||||||
|
prompt = f"""Classify each post as LEAD or NOT.
|
||||||
|
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
|
||||||
|
LEAD examples: "Need a website for my business", "Looking for web developer to build my site", "I need someone to create my website", "Want a new website for my company", "Looking for someone to design my WordPress site"
|
||||||
|
|
||||||
|
NOT LEAD:
|
||||||
|
- Offering web design services: "I build websites", "I offer web design", "Affordable web design packages"
|
||||||
|
- Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
|
||||||
|
- Recruiting employees, hiring staff, looking for business partners
|
||||||
|
- Selling products, promoting services, affiliate offers
|
||||||
|
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
||||||
|
|
||||||
|
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
|
||||||
|
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
|
||||||
|
Return a JSON array like ["yes","no","yes"] matching the order above."""
|
||||||
|
ai_succeeded = False
|
||||||
|
try:
|
||||||
|
raw = await ask_ollama(prompt)
|
||||||
|
raw = raw.strip()
|
||||||
|
# Strip markdown code fences
|
||||||
|
if raw.startswith("```"):
|
||||||
|
# Remove opening fence
|
||||||
|
first_nl = raw.find('\n')
|
||||||
|
if first_nl != -1:
|
||||||
|
raw = raw[first_nl + 1:]
|
||||||
|
# Remove closing fence
|
||||||
|
if raw.endswith("```"):
|
||||||
|
raw = raw[:-3]
|
||||||
|
raw = raw.strip()
|
||||||
|
answers = json.loads(raw)
|
||||||
|
if isinstance(answers, list) and len(answers) == len(results):
|
||||||
|
ai_succeeded = True
|
||||||
|
filtered = []
|
||||||
|
for i, ans in enumerate(answers):
|
||||||
|
if isinstance(ans, str) and ans.lower() == 'yes':
|
||||||
|
filtered.append(results[i])
|
||||||
|
if filtered:
|
||||||
|
return filtered[:10]
|
||||||
|
# AI successfully classified but returned all "no" — respect that decision
|
||||||
|
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
|
||||||
|
|
||||||
|
# Fallback: only use keyword filter when AI failed
|
||||||
|
if not ai_succeeded:
|
||||||
|
filtered = []
|
||||||
|
for r in results:
|
||||||
|
t = r['title'].lower()
|
||||||
|
if not kw_match_strict(t):
|
||||||
|
continue
|
||||||
|
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
|
||||||
|
'affordable web', 'web hosting', 'free website',
|
||||||
|
'limited time', 'special offer', 'sign up now',
|
||||||
|
'offer']):
|
||||||
|
continue
|
||||||
|
filtered.append(r)
|
||||||
|
return filtered[:10]
|
||||||
|
return []
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
class ScrapeRequest(BaseModel):
|
||||||
|
query: str = ""
|
||||||
|
|
||||||
|
@app.post("/scrape/requests")
|
||||||
|
async def scrape_requests(req: ScrapeRequest):
|
||||||
|
global last_scrape_time
|
||||||
|
now = datetime.now()
|
||||||
|
if last_scrape_time and (now - last_scrape_time) < timedelta(seconds=15):
|
||||||
|
return {"error": "rate_limited", "retry_after": 15}
|
||||||
|
last_scrape_time = now
|
||||||
|
results = await scrape_warriorforum()
|
||||||
|
if results:
|
||||||
|
results = await classify_leads(results)
|
||||||
|
return results[:10]
|
||||||
|
|
||||||
|
@app.post("/scrape/facebook")
|
||||||
|
async def scrape_facebook_endpoint():
|
||||||
|
results = await scrape_facebook()
|
||||||
|
if results:
|
||||||
|
results = await classify_leads(results)
|
||||||
|
return results[:15]
|
||||||
|
|
||||||
|
@app.post("/scrape/all")
|
||||||
|
async def scrape_all():
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
wf = await scrape_warriorforum()
|
||||||
|
if wf:
|
||||||
|
wf = await classify_leads(wf)
|
||||||
|
results.extend(wf[:5])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("WarriorForum scrape in /scrape/all failed: %s", e)
|
||||||
|
try:
|
||||||
|
fb = await scrape_facebook()
|
||||||
|
if fb:
|
||||||
|
fb = await classify_leads(fb)
|
||||||
|
results.extend(fb[:8])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Facebook scrape in /scrape/all failed: %s", e)
|
||||||
|
return results[:12]
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
python : INFO: Started server process [20044]
|
||||||
|
+ CategoryInfo : NotSpecified: (INFO: Started server process [20044]:String) [], RemoteException
|
||||||
|
+ FullyQualifiedErrorId : NativeCommandError
|
||||||
|
|
||||||
|
INFO: Waiting for application startup.
|
||||||
|
INFO: Application startup complete.
|
||||||
|
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[2026-06-22T19:24:48.030482] Browser Use service starting on port 3008
|
||||||
|
[2026-06-22T19:27:19.231701] Browser Use service starting on port 3008
|
||||||
|
[2026-06-22T19:28:28.335765] Browser Use service starting on port 3008
|
||||||
|
[2026-06-22T19:29:05.796265] Browser Use service starting on port 3008
|
||||||
|
[2026-06-22T19:29:17.042807] Scraping WarriorForum...
|
||||||
|
[2026-06-22T19:29:19.075166] WarriorForum: 55 results
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
INFO: Started server process [24268]
|
||||||
|
INFO: Waiting for application startup.
|
||||||
|
INFO: Application startup complete.
|
||||||
|
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Browser Use service starting on port 3008
|
||||||
|
INFO: 127.0.0.1:65003 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
Scraping WarriorForum...
|
||||||
|
WarriorForum: 55 results
|
||||||
|
INFO: 127.0.0.1:54587 - "POST /scrape/all HTTP/1.1" 200 OK
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# AI Sales Assistant — Self-Improvement Instructions
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This file contains the AI's own configuration, knowledge, and improvement rules.
|
||||||
|
The AI can read and modify this file to update its behavior at runtime.
|
||||||
|
|
||||||
|
## Current Instructions
|
||||||
|
- Always respond in English
|
||||||
|
- Keep responses under 300 words unless asked for detail
|
||||||
|
- Use bullet points for lists
|
||||||
|
- Be direct and actionable — no fluff
|
||||||
|
- Never mention being an AI or language model
|
||||||
|
- Refer to the user by their role (salesperson, admin, etc.)
|
||||||
|
- If unsure about a topic, say "I don't have that information yet" rather than guessing
|
||||||
|
|
||||||
|
## Knowledge Base
|
||||||
|
### Sales Tips
|
||||||
|
- Cold emails should be under 150 words
|
||||||
|
- Follow up within 48 hours
|
||||||
|
- Personalise every outreach with the prospect's name and company
|
||||||
|
- Use open-ended questions in discovery calls
|
||||||
|
- Always ask for the next step before ending a call
|
||||||
|
|
||||||
|
### Job Targeting
|
||||||
|
- Developers respond best to technical value props
|
||||||
|
- Marketing managers care about ROI and metrics
|
||||||
|
- C-level executives want brevity and business impact
|
||||||
|
|
||||||
|
## Improvement Log
|
||||||
|
Track changes made by the AI to improve itself:
|
||||||
|
- (initial) Basic instructions and knowledge base created
|
||||||
|
|
||||||
|
## Self-Modification Rules
|
||||||
|
The AI may update this file when:
|
||||||
|
1. It identifies a gap in its knowledge that would help salespeople
|
||||||
|
2. It discovers a better way to structure responses
|
||||||
|
3. A user explicitly requests an update to behavior
|
||||||
|
4. It notices repeated questions that aren't well-covered
|
||||||
|
|
||||||
|
Only append to the Improvement Log — don't delete previous entries.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{"job_title":"Software Developer","keywords":["developer","programmer","software engineer","coder","full stack","backend","frontend"],"industry":"Technology","description":"Builds and maintains software applications and systems"}
|
||||||
|
{"job_title":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"}
|
||||||
|
{"job_title":"Sales Representative","keywords":["sales rep","account executive","business development","sales consultant"],"industry":"Sales","description":"Drives revenue through client acquisition and relationship management"}
|
||||||
|
{"job_title":"Project Manager","keywords":["project manager","program manager","scrum master","agile coach"],"industry":"Business","description":"Oversees project timelines, resources, and deliverables"}
|
||||||
|
{"job_title":"Graphic Designer","keywords":["designer","graphic designer","ui designer","ux designer","visual designer"],"industry":"Creative","description":"Creates visual concepts and designs for digital and print media"}
|
||||||
|
{"job_title":"Data Analyst","keywords":["data analyst","business analyst","data scientist","analytics"],"industry":"Technology","description":"Analyzes data to provide actionable business insights"}
|
||||||
|
{"job_title":"Customer Support Specialist","keywords":["customer support","customer service","support agent","help desk"],"industry":"Customer Service","description":"Assists customers with inquiries, issues, and product support"}
|
||||||
|
{"job_title":"Human Resources Manager","keywords":["HR manager","HR","recruiter","talent acquisition","people operations"],"industry":"Human Resources","description":"Manages recruitment, employee relations, and HR operations"}
|
||||||
|
{"job_title":"Financial Advisor","keywords":["financial advisor","financial planner","wealth manager","investment advisor"],"industry":"Finance","description":"Provides financial guidance and investment planning to clients"}
|
||||||
|
{"job_title":"Operations Manager","keywords":["operations manager","operations","logistics","supply chain"],"industry":"Business","description":"Oversees daily operations and process optimization"}
|
||||||
+56
-47
@@ -22,7 +22,7 @@ $$ LANGUAGE plpgsql;
|
|||||||
-- 1. AUTHENTICATION & ROLE MANAGEMENT
|
-- 1. AUTHENTICATION & ROLE MANAGEMENT
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE roles (
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
display_name VARCHAR(100),
|
display_name VARCHAR(100),
|
||||||
@@ -35,7 +35,7 @@ CREATE TABLE roles (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX uq_roles_name ON roles(name);
|
CREATE UNIQUE INDEX uq_roles_name ON roles(name);
|
||||||
|
|
||||||
CREATE TABLE permissions (
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
resource VARCHAR(100) NOT NULL,
|
resource VARCHAR(100) NOT NULL,
|
||||||
action VARCHAR(50) NOT NULL,
|
action VARCHAR(50) NOT NULL,
|
||||||
@@ -46,7 +46,7 @@ CREATE TABLE permissions (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX uq_permissions ON permissions(resource, action);
|
CREATE UNIQUE INDEX uq_permissions ON permissions(resource, action);
|
||||||
|
|
||||||
CREATE TABLE role_permissions (
|
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||||
granted_by UUID,
|
granted_by UUID,
|
||||||
@@ -54,7 +54,7 @@ CREATE TABLE role_permissions (
|
|||||||
PRIMARY KEY (role_id, permission_id)
|
PRIMARY KEY (role_id, permission_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
username VARCHAR(100) NOT NULL,
|
username VARCHAR(100) NOT NULL,
|
||||||
email VARCHAR(255) NOT NULL,
|
email VARCHAR(255) NOT NULL,
|
||||||
@@ -83,7 +83,7 @@ CREATE INDEX idx_users_is_active ON users(is_active) WHERE deleted_at IS NULL;
|
|||||||
CREATE INDEX idx_users_is_locked ON users(is_locked) WHERE is_locked = TRUE AND deleted_at IS NULL;
|
CREATE INDEX idx_users_is_locked ON users(is_locked) WHERE is_locked = TRUE AND deleted_at IS NULL;
|
||||||
CREATE INDEX idx_users_created_by ON users(created_by);
|
CREATE INDEX idx_users_created_by ON users(created_by);
|
||||||
|
|
||||||
CREATE TABLE user_roles (
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
assigned_by UUID REFERENCES users(id),
|
assigned_by UUID REFERENCES users(id),
|
||||||
@@ -98,7 +98,7 @@ CREATE INDEX idx_user_roles_role ON user_roles(role_id);
|
|||||||
-- 2. BAN & SUSPENSION SYSTEM
|
-- 2. BAN & SUSPENSION SYSTEM
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE banned_users (
|
CREATE TABLE IF NOT EXISTS banned_users (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
banned_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
banned_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
banned_by UUID NOT NULL REFERENCES users(id),
|
banned_by UUID NOT NULL REFERENCES users(id),
|
||||||
@@ -119,10 +119,10 @@ CREATE TABLE banned_users (
|
|||||||
|
|
||||||
CREATE INDEX idx_banned_users_user ON banned_users(banned_user_id);
|
CREATE INDEX idx_banned_users_user ON banned_users(banned_user_id);
|
||||||
CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
|
CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
|
||||||
WHERE is_reversed = FALSE AND (permanent_ban = TRUE OR expires_at > NOW());
|
WHERE is_reversed = FALSE;
|
||||||
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
|
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
|
||||||
|
|
||||||
CREATE TABLE suspended_users (
|
CREATE TABLE IF NOT EXISTS suspended_users (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
suspended_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
suspended_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
suspended_by UUID NOT NULL REFERENCES users(id),
|
suspended_by UUID NOT NULL REFERENCES users(id),
|
||||||
@@ -138,14 +138,14 @@ CREATE TABLE suspended_users (
|
|||||||
|
|
||||||
CREATE INDEX idx_suspended_users_user ON suspended_users(suspended_user_id);
|
CREATE INDEX idx_suspended_users_user ON suspended_users(suspended_user_id);
|
||||||
CREATE INDEX idx_suspended_users_active ON suspended_users(suspended_user_id)
|
CREATE INDEX idx_suspended_users_active ON suspended_users(suspended_user_id)
|
||||||
WHERE is_active = TRUE AND expires_at > NOW();
|
WHERE is_active = TRUE;
|
||||||
CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
|
CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- 3. SESSION & LOGIN MANAGEMENT
|
-- 3. SESSION & LOGIN MANAGEMENT
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
token_hash VARCHAR(255) NOT NULL,
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
@@ -161,7 +161,7 @@ CREATE INDEX idx_sessions_user ON sessions(user_id) WHERE is_active = TRUE;
|
|||||||
CREATE UNIQUE INDEX uq_sessions_token ON sessions(token_hash);
|
CREATE UNIQUE INDEX uq_sessions_token ON sessions(token_hash);
|
||||||
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE;
|
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE;
|
||||||
|
|
||||||
CREATE TABLE login_attempts (
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID REFERENCES users(id),
|
user_id UUID REFERENCES users(id),
|
||||||
username_attempted VARCHAR(100) NOT NULL,
|
username_attempted VARCHAR(100) NOT NULL,
|
||||||
@@ -181,7 +181,7 @@ CREATE INDEX idx_login_attempts_time ON login_attempts(attempted_at DESC);
|
|||||||
-- 4. CUSTOMER MANAGEMENT
|
-- 4. CUSTOMER MANAGEMENT
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE customer_statuses (
|
CREATE TABLE IF NOT EXISTS customer_statuses (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -196,7 +196,7 @@ CREATE TABLE customer_statuses (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX uq_customer_statuses_name ON customer_statuses(name) WHERE deleted_at IS NULL;
|
CREATE UNIQUE INDEX uq_customer_statuses_name ON customer_statuses(name) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE customers (
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_type VARCHAR(20) NOT NULL,
|
customer_type VARCHAR(20) NOT NULL,
|
||||||
status_id UUID NOT NULL REFERENCES customer_statuses(id),
|
status_id UUID NOT NULL REFERENCES customer_statuses(id),
|
||||||
@@ -218,7 +218,7 @@ CREATE INDEX idx_customers_score ON customers(score DESC) WHERE deleted_at IS NU
|
|||||||
CREATE INDEX idx_customers_tags ON customers USING GIN(tags) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_customers_tags ON customers USING GIN(tags) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_customers_created ON customers(created_at DESC) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_customers_created ON customers(created_at DESC) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE individual_customers (
|
CREATE TABLE IF NOT EXISTS individual_customers (
|
||||||
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
|
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
first_name VARCHAR(100) NOT NULL,
|
first_name VARCHAR(100) NOT NULL,
|
||||||
last_name VARCHAR(100) NOT NULL,
|
last_name VARCHAR(100) NOT NULL,
|
||||||
@@ -233,7 +233,7 @@ CREATE TABLE individual_customers (
|
|||||||
|
|
||||||
CREATE INDEX idx_individual_names ON individual_customers(last_name, first_name);
|
CREATE INDEX idx_individual_names ON individual_customers(last_name, first_name);
|
||||||
|
|
||||||
CREATE TABLE company_customers (
|
CREATE TABLE IF NOT EXISTS company_customers (
|
||||||
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
|
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
company_name VARCHAR(255) NOT NULL,
|
company_name VARCHAR(255) NOT NULL,
|
||||||
registration_number VARCHAR(100),
|
registration_number VARCHAR(100),
|
||||||
@@ -250,7 +250,7 @@ CREATE TABLE company_customers (
|
|||||||
CREATE INDEX idx_company_name ON company_customers(company_name);
|
CREATE INDEX idx_company_name ON company_customers(company_name);
|
||||||
CREATE INDEX idx_company_industry ON company_customers(industry);
|
CREATE INDEX idx_company_industry ON company_customers(industry);
|
||||||
|
|
||||||
CREATE TABLE contact_information (
|
CREATE TABLE IF NOT EXISTS contact_information (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
type VARCHAR(20) NOT NULL,
|
type VARCHAR(20) NOT NULL,
|
||||||
@@ -270,7 +270,7 @@ CREATE INDEX idx_contacts_type ON contact_information(type) WHERE deleted_at IS
|
|||||||
CREATE INDEX idx_contacts_value ON contact_information(value) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_contacts_value ON contact_information(value) WHERE deleted_at IS NULL;
|
||||||
CREATE UNIQUE INDEX uq_contacts_primary ON contact_information(customer_id, type) WHERE is_primary = TRUE AND deleted_at IS NULL;
|
CREATE UNIQUE INDEX uq_contacts_primary ON contact_information(customer_id, type) WHERE is_primary = TRUE AND deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE addresses (
|
CREATE TABLE IF NOT EXISTS addresses (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
address_type VARCHAR(20) NOT NULL,
|
address_type VARCHAR(20) NOT NULL,
|
||||||
@@ -294,7 +294,7 @@ CREATE INDEX idx_addresses_country ON addresses(country) WHERE deleted_at IS NUL
|
|||||||
CREATE INDEX idx_addresses_city ON addresses(city) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_addresses_city ON addresses(city) WHERE deleted_at IS NULL;
|
||||||
CREATE UNIQUE INDEX uq_addresses_primary ON addresses(customer_id, address_type) WHERE is_primary = TRUE AND deleted_at IS NULL;
|
CREATE UNIQUE INDEX uq_addresses_primary ON addresses(customer_id, address_type) WHERE is_primary = TRUE AND deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE customer_notes (
|
CREATE TABLE IF NOT EXISTS customer_notes (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
author_id UUID NOT NULL REFERENCES users(id),
|
author_id UUID NOT NULL REFERENCES users(id),
|
||||||
@@ -315,7 +315,7 @@ CREATE INDEX idx_notes_pinned ON customer_notes(is_pinned) WHERE is_pinned = TRU
|
|||||||
-- 5. CUSTOMER & INCIDENT REPORTS
|
-- 5. CUSTOMER & INCIDENT REPORTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE customer_reports (
|
CREATE TABLE IF NOT EXISTS customer_reports (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||||
reported_by UUID NOT NULL REFERENCES users(id),
|
reported_by UUID NOT NULL REFERENCES users(id),
|
||||||
@@ -339,7 +339,7 @@ CREATE INDEX idx_customer_reports_reported_by ON customer_reports(reported_by);
|
|||||||
CREATE INDEX idx_customer_reports_status ON customer_reports(status);
|
CREATE INDEX idx_customer_reports_status ON customer_reports(status);
|
||||||
CREATE INDEX idx_customer_reports_severity ON customer_reports(severity);
|
CREATE INDEX idx_customer_reports_severity ON customer_reports(severity);
|
||||||
|
|
||||||
CREATE TABLE incident_reports (
|
CREATE TABLE IF NOT EXISTS incident_reports (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
reported_by UUID NOT NULL REFERENCES users(id),
|
reported_by UUID NOT NULL REFERENCES users(id),
|
||||||
target_user_id UUID REFERENCES users(id),
|
target_user_id UUID REFERENCES users(id),
|
||||||
@@ -368,7 +368,7 @@ CREATE INDEX idx_incident_reports_severity ON incident_reports(severity);
|
|||||||
-- 6. LEAD MANAGEMENT
|
-- 6. LEAD MANAGEMENT
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE lead_sources (
|
CREATE TABLE IF NOT EXISTS lead_sources (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -380,7 +380,7 @@ CREATE TABLE lead_sources (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX uq_lead_sources_name ON lead_sources(name) WHERE deleted_at IS NULL;
|
CREATE UNIQUE INDEX uq_lead_sources_name ON lead_sources(name) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE lead_stages (
|
CREATE TABLE IF NOT EXISTS lead_stages (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -395,7 +395,7 @@ CREATE TABLE lead_stages (
|
|||||||
CREATE UNIQUE INDEX uq_lead_stages_name ON lead_stages(name) WHERE deleted_at IS NULL;
|
CREATE UNIQUE INDEX uq_lead_stages_name ON lead_stages(name) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_lead_stages_sort ON lead_stages(sort_order);
|
CREATE INDEX idx_lead_stages_sort ON lead_stages(sort_order);
|
||||||
|
|
||||||
CREATE TABLE leads (
|
CREATE TABLE IF NOT EXISTS leads (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
source_id UUID REFERENCES lead_sources(id),
|
source_id UUID REFERENCES lead_sources(id),
|
||||||
stage_id UUID NOT NULL REFERENCES lead_stages(id),
|
stage_id UUID NOT NULL REFERENCES lead_stages(id),
|
||||||
@@ -431,7 +431,7 @@ CREATE INDEX idx_leads_converted ON leads(converted_customer_id) WHERE converted
|
|||||||
CREATE INDEX idx_leads_email ON leads(email) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_leads_email ON leads(email) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_leads_active ON leads(stage_id, assigned_to) WHERE deleted_at IS NULL AND converted_customer_id IS NULL;
|
CREATE INDEX idx_leads_active ON leads(stage_id, assigned_to) WHERE deleted_at IS NULL AND converted_customer_id IS NULL;
|
||||||
|
|
||||||
CREATE TABLE lead_conversions (
|
CREATE TABLE IF NOT EXISTS lead_conversions (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
@@ -447,7 +447,7 @@ CREATE INDEX idx_lead_conversions_customer ON lead_conversions(customer_id);
|
|||||||
-- 7. PRODUCT CATALOG
|
-- 7. PRODUCT CATALOG
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE product_categories (
|
CREATE TABLE IF NOT EXISTS product_categories (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -462,7 +462,7 @@ CREATE TABLE product_categories (
|
|||||||
CREATE UNIQUE INDEX uq_product_categories_name ON product_categories(name) WHERE deleted_at IS NULL;
|
CREATE UNIQUE INDEX uq_product_categories_name ON product_categories(name) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_product_categories_parent ON product_categories(parent_id);
|
CREATE INDEX idx_product_categories_parent ON product_categories(parent_id);
|
||||||
|
|
||||||
CREATE TABLE products (
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
category_id UUID REFERENCES product_categories(id),
|
category_id UUID REFERENCES product_categories(id),
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
@@ -487,7 +487,7 @@ CREATE INDEX idx_products_active ON products(is_active) WHERE deleted_at IS NULL
|
|||||||
-- 8. SALES PIPELINE
|
-- 8. SALES PIPELINE
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE deal_stages (
|
CREATE TABLE IF NOT EXISTS deal_stages (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -502,7 +502,7 @@ CREATE TABLE deal_stages (
|
|||||||
CREATE UNIQUE INDEX uq_deal_stages_name ON deal_stages(name) WHERE deleted_at IS NULL;
|
CREATE UNIQUE INDEX uq_deal_stages_name ON deal_stages(name) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_deal_stages_sort ON deal_stages(sort_order);
|
CREATE INDEX idx_deal_stages_sort ON deal_stages(sort_order);
|
||||||
|
|
||||||
CREATE TABLE opportunities (
|
CREATE TABLE IF NOT EXISTS opportunities (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||||
lead_id UUID REFERENCES leads(id),
|
lead_id UUID REFERENCES leads(id),
|
||||||
@@ -530,7 +530,7 @@ CREATE INDEX idx_opportunities_won ON opportunities(is_won) WHERE deleted_at IS
|
|||||||
CREATE INDEX idx_opportunities_created ON opportunities(created_at DESC) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_opportunities_created ON opportunities(created_at DESC) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_opportunities_owner_stage ON opportunities(owner_id, stage_id) WHERE deleted_at IS NULL AND is_won IS NULL;
|
CREATE INDEX idx_opportunities_owner_stage ON opportunities(owner_id, stage_id) WHERE deleted_at IS NULL AND is_won IS NULL;
|
||||||
|
|
||||||
CREATE TABLE opportunity_products (
|
CREATE TABLE IF NOT EXISTS opportunity_products (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
|
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
|
||||||
product_id UUID NOT NULL REFERENCES products(id),
|
product_id UUID NOT NULL REFERENCES products(id),
|
||||||
@@ -549,7 +549,7 @@ CREATE INDEX idx_opp_products_product ON opportunity_products(product_id);
|
|||||||
-- 9. COMMUNICATION TRACKING
|
-- 9. COMMUNICATION TRACKING
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE communication_types (
|
CREATE TABLE IF NOT EXISTS communication_types (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -561,7 +561,7 @@ CREATE TABLE communication_types (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX uq_comm_types_name ON communication_types(name);
|
CREATE UNIQUE INDEX uq_comm_types_name ON communication_types(name);
|
||||||
|
|
||||||
CREATE TABLE communications (
|
CREATE TABLE IF NOT EXISTS communications (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||||
opportunity_id UUID REFERENCES opportunities(id),
|
opportunity_id UUID REFERENCES opportunities(id),
|
||||||
@@ -585,7 +585,7 @@ CREATE INDEX idx_communications_type ON communications(type_id) WHERE deleted_at
|
|||||||
CREATE INDEX idx_communications_created_by ON communications(created_by) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_communications_created_by ON communications(created_by) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_communications_started ON communications(started_at) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_communications_started ON communications(started_at) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE communication_participants (
|
CREATE TABLE IF NOT EXISTS communication_participants (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
communication_id UUID NOT NULL REFERENCES communications(id) ON DELETE CASCADE,
|
communication_id UUID NOT NULL REFERENCES communications(id) ON DELETE CASCADE,
|
||||||
user_id UUID REFERENCES users(id),
|
user_id UUID REFERENCES users(id),
|
||||||
@@ -602,7 +602,7 @@ CREATE INDEX idx_comm_participants_user ON communication_participants(user_id);
|
|||||||
-- 10. TASKS AND ACTIVITIES
|
-- 10. TASKS AND ACTIVITIES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE task_priorities (
|
CREATE TABLE IF NOT EXISTS task_priorities (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
color VARCHAR(7),
|
color VARCHAR(7),
|
||||||
@@ -613,7 +613,7 @@ CREATE TABLE task_priorities (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX uq_task_priorities_name ON task_priorities(name);
|
CREATE UNIQUE INDEX uq_task_priorities_name ON task_priorities(name);
|
||||||
|
|
||||||
CREATE TABLE tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID REFERENCES customers(id),
|
customer_id UUID REFERENCES customers(id),
|
||||||
opportunity_id UUID REFERENCES opportunities(id),
|
opportunity_id UUID REFERENCES opportunities(id),
|
||||||
@@ -645,7 +645,7 @@ CREATE INDEX idx_tasks_reminder ON tasks(reminder_at) WHERE reminder_sent = FALS
|
|||||||
-- 11. INVOICE SUPPORT
|
-- 11. INVOICE SUPPORT
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE invoice_statuses (
|
CREATE TABLE IF NOT EXISTS invoice_statuses (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -657,7 +657,7 @@ CREATE TABLE invoice_statuses (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX uq_invoice_statuses_name ON invoice_statuses(name);
|
CREATE UNIQUE INDEX uq_invoice_statuses_name ON invoice_statuses(name);
|
||||||
|
|
||||||
CREATE TABLE invoices (
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||||
opportunity_id UUID REFERENCES opportunities(id),
|
opportunity_id UUID REFERENCES opportunities(id),
|
||||||
@@ -687,9 +687,9 @@ CREATE INDEX idx_invoices_customer ON invoices(customer_id) WHERE deleted_at IS
|
|||||||
CREATE INDEX idx_invoices_status ON invoices(status_id) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_invoices_status ON invoices(status_id) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_invoices_overdue ON invoices(due_date, status_id) WHERE deleted_at IS NULL AND due_date < NOW();
|
-- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE)
|
||||||
|
|
||||||
CREATE TABLE invoice_items (
|
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
product_id UUID REFERENCES products(id),
|
product_id UUID REFERENCES products(id),
|
||||||
@@ -705,7 +705,7 @@ CREATE TABLE invoice_items (
|
|||||||
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
|
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
|
||||||
CREATE INDEX idx_invoice_items_product ON invoice_items(product_id);
|
CREATE INDEX idx_invoice_items_product ON invoice_items(product_id);
|
||||||
|
|
||||||
CREATE TABLE payments (
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
invoice_id UUID NOT NULL REFERENCES invoices(id),
|
invoice_id UUID NOT NULL REFERENCES invoices(id),
|
||||||
amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
|
amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
|
||||||
@@ -729,7 +729,7 @@ CREATE INDEX idx_payments_transaction ON payments(transaction_id) WHERE transact
|
|||||||
-- 12. AUDIT LOGGING
|
-- 12. AUDIT LOGGING
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE audit_logs (
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
table_name VARCHAR(100) NOT NULL,
|
table_name VARCHAR(100) NOT NULL,
|
||||||
record_id UUID NOT NULL,
|
record_id UUID NOT NULL,
|
||||||
@@ -755,7 +755,7 @@ CREATE INDEX idx_audit_session ON audit_logs(session_id);
|
|||||||
-- 13. DUPLICATE DETECTION
|
-- 13. DUPLICATE DETECTION
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
CREATE TABLE customer_duplicates (
|
CREATE TABLE IF NOT EXISTS customer_duplicates (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
duplicate_customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
duplicate_customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
@@ -800,17 +800,21 @@ DECLARE
|
|||||||
t TEXT;
|
t TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR t IN
|
FOR t IN
|
||||||
SELECT table_name FROM information_schema.columns
|
SELECT table_name FROM information_schema.tables
|
||||||
WHERE column_name = 'updated_at'
|
WHERE table_schema = 'public'
|
||||||
AND table_schema = 'public'
|
|
||||||
AND table_type = 'BASE TABLE'
|
AND table_type = 'BASE TABLE'
|
||||||
|
AND table_name IN (
|
||||||
|
SELECT table_name FROM information_schema.columns
|
||||||
|
WHERE column_name = 'updated_at'
|
||||||
|
AND table_schema = 'public'
|
||||||
|
)
|
||||||
LOOP
|
LOOP
|
||||||
EXECUTE format(
|
EXECUTE format(
|
||||||
'CREATE TRIGGER trg_%I_updated_at
|
'DROP TRIGGER IF EXISTS trg_%I_updated_at ON %I; CREATE TRIGGER trg_%I_updated_at
|
||||||
BEFORE UPDATE ON %I
|
BEFORE UPDATE ON %I
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION update_updated_at_column()',
|
EXECUTE FUNCTION update_updated_at_column()',
|
||||||
t, t
|
t, t, t, t
|
||||||
);
|
);
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END;
|
END;
|
||||||
@@ -881,6 +885,7 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_enforce_create_user ON users;
|
||||||
CREATE TRIGGER trg_enforce_create_user
|
CREATE TRIGGER trg_enforce_create_user
|
||||||
BEFORE INSERT ON users
|
BEFORE INSERT ON users
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
@@ -931,6 +936,7 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_enforce_role_assignment ON user_roles;
|
||||||
CREATE TRIGGER trg_enforce_role_assignment
|
CREATE TRIGGER trg_enforce_role_assignment
|
||||||
BEFORE INSERT ON user_roles
|
BEFORE INSERT ON user_roles
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
@@ -969,7 +975,7 @@ BEGIN
|
|||||||
audit_action,
|
audit_action,
|
||||||
CASE WHEN audit_action IN ('UPDATE', 'DELETE') THEN old_row ELSE NULL END,
|
CASE WHEN audit_action IN ('UPDATE', 'DELETE') THEN old_row ELSE NULL END,
|
||||||
CASE WHEN audit_action IN ('CREATE', 'UPDATE') THEN new_row ELSE NULL END,
|
CASE WHEN audit_action IN ('CREATE', 'UPDATE') THEN new_row ELSE NULL END,
|
||||||
NULL
|
NULLIF(current_setting('app.current_user_id', true), '')
|
||||||
);
|
);
|
||||||
|
|
||||||
RETURN COALESCE(NEW, OLD);
|
RETURN COALESCE(NEW, OLD);
|
||||||
@@ -994,6 +1000,7 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_ban ON banned_users;
|
||||||
CREATE TRIGGER trg_audit_ban
|
CREATE TRIGGER trg_audit_ban
|
||||||
AFTER INSERT OR UPDATE ON banned_users
|
AFTER INSERT OR UPDATE ON banned_users
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
@@ -1017,6 +1024,7 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_suspension ON suspended_users;
|
||||||
CREATE TRIGGER trg_audit_suspension
|
CREATE TRIGGER trg_audit_suspension
|
||||||
AFTER INSERT OR UPDATE ON suspended_users
|
AFTER INSERT OR UPDATE ON suspended_users
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
@@ -1047,6 +1055,7 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
|
||||||
CREATE TRIGGER trg_audit_login
|
CREATE TRIGGER trg_audit_login
|
||||||
AFTER INSERT ON login_attempts
|
AFTER INSERT ON login_attempts
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
+143
-143
@@ -35,78 +35,78 @@ ON CONFLICT (name) DO NOTHING;
|
|||||||
|
|
||||||
-- USER MANAGEMENT (SUPER_ADMIN only)
|
-- USER MANAGEMENT (SUPER_ADMIN only)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'),
|
('a0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'),
|
||||||
('p0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'),
|
('a0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'),
|
||||||
('p0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'),
|
('a0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'),
|
||||||
('p0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'),
|
('a0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'),
|
||||||
('p0010005-0000-0000-0000-000000000000', 'users', 'assign_roles', 'Assign roles to users', 'User Management'),
|
('a0010005-0000-0000-0000-000000000000', 'users', 'assign_roles', 'Assign roles to users', 'User Management'),
|
||||||
('p0010006-0000-0000-0000-000000000000', 'users', 'promote', 'Promote users to higher roles', 'User Management'),
|
('a0010006-0000-0000-0000-000000000000', 'users', 'promote', 'Promote users to higher roles', 'User Management'),
|
||||||
('p0010007-0000-0000-0000-000000000000', 'users', 'demote', 'Demote users to lower roles', 'User Management'),
|
('a0010007-0000-0000-0000-000000000000', 'users', 'demote', 'Demote users to lower roles', 'User Management'),
|
||||||
('p0010008-0000-0000-0000-000000000000', 'users', 'reset_password', 'Reset user passwords', 'User Management'),
|
('a0010008-0000-0000-0000-000000000000', 'users', 'reset_password', 'Reset user passwords', 'User Management'),
|
||||||
('p0010009-0000-0000-0000-000000000000', 'users', 'disable', 'Disable/disable user accounts', 'User Management'),
|
('a0010009-0000-0000-0000-000000000000', 'users', 'disable', 'Disable/disable user accounts', 'User Management'),
|
||||||
('p0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management')
|
('a0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- BAN & SUSPENSION (ADMIN + SUPER_ADMIN)
|
-- BAN & SUSPENSION (ADMIN + SUPER_ADMIN)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'),
|
('a0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'),
|
||||||
('p0020002-0000-0000-0000-000000000000', 'bans', 'reverse', 'Reverse a ban on a user', 'Ban Management'),
|
('a0020002-0000-0000-0000-000000000000', 'bans', 'reverse', 'Reverse a ban on a user', 'Ban Management'),
|
||||||
('p0020003-0000-0000-0000-000000000000', 'bans', 'permanently_delete', 'Permanently delete banned users', 'Ban Management'),
|
('a0020003-0000-0000-0000-000000000000', 'bans', 'permanently_delete', 'Permanently delete banned users', 'Ban Management'),
|
||||||
('p0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'),
|
('a0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'),
|
||||||
('p0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'),
|
('a0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'),
|
||||||
('p0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management')
|
('a0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- CRM CORE (SALES_USER + MANAGERS)
|
-- CRM CORE (SALES_USER + MANAGERS)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'),
|
('a0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'),
|
||||||
('p0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'),
|
('a0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'),
|
||||||
('p0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'),
|
('a0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'),
|
||||||
('p0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'),
|
('a0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'),
|
||||||
('p0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'),
|
('a0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'),
|
||||||
('p0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'),
|
('a0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'),
|
||||||
('p0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'),
|
('a0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'),
|
||||||
('p0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'),
|
('a0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'),
|
||||||
('p0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'),
|
('a0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'),
|
||||||
('p0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'),
|
('a0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'),
|
||||||
('p0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'),
|
('a0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'),
|
||||||
('p0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'),
|
('a0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'),
|
||||||
('p0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'),
|
('a0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'),
|
||||||
('p0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'),
|
('a0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'),
|
||||||
('p0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'),
|
('a0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'),
|
||||||
('p0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'),
|
('a0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'),
|
||||||
('p0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM')
|
('a0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- REPORTING & INCIDENTS (ADMIN)
|
-- REPORTING & INCIDENTS (ADMIN)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'),
|
('a0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'),
|
||||||
('p0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'),
|
('a0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'),
|
||||||
('p0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'),
|
('a0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'),
|
||||||
('p0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'),
|
('a0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'),
|
||||||
('p0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'),
|
('a0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'),
|
||||||
('p0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'),
|
('a0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'),
|
||||||
('p0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting')
|
('a0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN)
|
-- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN)
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'),
|
('a0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'),
|
||||||
('p0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'),
|
('a0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'),
|
||||||
('p0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'),
|
('a0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'),
|
||||||
('p0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'),
|
('a0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'),
|
||||||
('p0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'),
|
('a0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'),
|
||||||
('p0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System')
|
('a0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- DEVELOPER PERMISSIONS
|
-- DEVELOPER PERMISSIONS
|
||||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||||
('p0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'),
|
('a0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'),
|
||||||
('p0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'),
|
('a0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'),
|
||||||
('p0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'),
|
('a0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'),
|
||||||
('p0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'),
|
('a0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'),
|
||||||
('p0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'),
|
('a0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'),
|
||||||
('p0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer')
|
('a0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -121,70 +121,70 @@ ON CONFLICT DO NOTHING;
|
|||||||
-- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs
|
-- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs
|
||||||
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||||
-- Bans & Suspensions
|
-- Bans & Suspensions
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0020001-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0020001-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0020004-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0020004-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0020005-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0020005-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0020006-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0020006-0000-0000-0000-000000000000', NULL),
|
||||||
-- User read + disable
|
-- User read + disable
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-0000-000000000000', 'p0010009-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0010009-0000-0000-0000-000000000000', NULL),
|
||||||
-- Reporting & Incidents
|
-- Reporting & Incidents
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040001-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040001-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040002-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040003-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040003-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040004-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040004-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040005-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040005-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040006-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040006-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0040007-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0040007-0000-0000-0000-000000000000', NULL),
|
||||||
-- CRM read access
|
-- CRM read access
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL),
|
('00000002-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL),
|
||||||
-- Audit read
|
-- Audit read
|
||||||
('00000002-0000-0000-0000-000000000000', 'p0050002-0000-0000-0000-000000000000', NULL)
|
('00000002-0000-0000-0000-000000000000', 'a0050002-0000-0000-0000-000000000000', NULL)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- SALES_USER — CRM operations
|
-- SALES_USER — CRM operations
|
||||||
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030001-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030001-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030003-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030003-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030005-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030005-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030007-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030007-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030008-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030008-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030009-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030009-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030011-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030011-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030012-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030012-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030013-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030013-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030015-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030015-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL),
|
('00000003-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
|
||||||
('00000003-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL)
|
('00000003-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility)
|
-- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility)
|
||||||
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||||
-- Developer technical permissions
|
-- Developer technical permissions
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060001-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060001-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060002-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060003-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060003-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060004-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060004-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060005-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060005-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0060006-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0060006-0000-0000-0000-000000000000', NULL),
|
||||||
-- User self-view
|
-- User self-view
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL),
|
||||||
-- CRM access (matching SALES_USER level for post-sale project visibility)
|
-- CRM access (matching SALES_USER level for post-sale project visibility)
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL),
|
('00000004-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
|
||||||
('00000004-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL)
|
('00000004-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -197,19 +197,19 @@ ON CONFLICT DO NOTHING;
|
|||||||
-- dev_demo / DevTesting@2026
|
-- dev_demo / DevTesting@2026
|
||||||
|
|
||||||
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
|
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
|
||||||
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@crm.demo',
|
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
|
||||||
'$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC',
|
'$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC',
|
||||||
'Super', 'Admin', TRUE, FALSE),
|
'Super', 'Admin', TRUE, FALSE),
|
||||||
('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@crm.demo',
|
('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@coastit.co.za',
|
||||||
'$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy',
|
'$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy',
|
||||||
'System', 'Admin', TRUE, TRUE),
|
'System', 'Admin', TRUE, TRUE),
|
||||||
('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@crm.demo',
|
('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@coastit.co.za',
|
||||||
'$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W',
|
'$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W',
|
||||||
'Sales', 'User', TRUE, TRUE),
|
'Sales', 'User', TRUE, TRUE),
|
||||||
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@crm.demo',
|
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
|
||||||
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
||||||
'Dev', 'User', TRUE, TRUE)
|
'Dev', 'User', TRUE, TRUE)
|
||||||
ON CONFLICT (username) DO NOTHING;
|
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
|
||||||
|
|
||||||
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
||||||
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
||||||
@@ -288,41 +288,41 @@ ON CONFLICT DO NOTHING;
|
|||||||
|
|
||||||
-- Communication Types
|
-- Communication Types
|
||||||
INSERT INTO communication_types (id, name, description, icon) VALUES
|
INSERT INTO communication_types (id, name, description, icon) VALUES
|
||||||
('g0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'),
|
('b0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'),
|
||||||
('g0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'),
|
('b0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'),
|
||||||
('g0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'),
|
('b0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'),
|
||||||
('g0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'),
|
('b0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'),
|
||||||
('g0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'),
|
('b0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'),
|
||||||
('g0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'),
|
('b0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'),
|
||||||
('g0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text')
|
('b0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Task Priorities
|
-- Task Priorities
|
||||||
INSERT INTO task_priorities (id, name, color, sort_order) VALUES
|
INSERT INTO task_priorities (id, name, color, sort_order) VALUES
|
||||||
('h0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1),
|
('c0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1),
|
||||||
('h0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2),
|
('c0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2),
|
||||||
('h0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3),
|
('c0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3),
|
||||||
('h0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4)
|
('c0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Invoice Statuses
|
-- Invoice Statuses
|
||||||
INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES
|
INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES
|
||||||
('i0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1),
|
('d0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1),
|
||||||
('i0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2),
|
('d0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2),
|
||||||
('i0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3),
|
('d0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3),
|
||||||
('i0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4),
|
('d0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4),
|
||||||
('i0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5),
|
('d0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5),
|
||||||
('i0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6),
|
('d0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6),
|
||||||
('i0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7)
|
('d0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Product Categories
|
-- Product Categories
|
||||||
INSERT INTO product_categories (id, name, description, sort_order) VALUES
|
INSERT INTO product_categories (id, name, description, sort_order) VALUES
|
||||||
('j0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1),
|
('e0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1),
|
||||||
('j0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2),
|
('e0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2),
|
||||||
('j0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3),
|
('e0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3),
|
||||||
('j0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4),
|
('e0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4),
|
||||||
('j0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5)
|
('e0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -331,12 +331,12 @@ ON CONFLICT DO NOTHING;
|
|||||||
|
|
||||||
-- Sample Products
|
-- Sample Products
|
||||||
INSERT INTO products (id, category_id, name, description, sku, unit_price, cost_price) VALUES
|
INSERT INTO products (id, category_id, name, description, sku, unit_price, cost_price) VALUES
|
||||||
('k0000000-0000-0000-0000-000000000001', 'j0000000-0000-0000-0000-000000000001', 'CRM Pro License', 'Enterprise CRM license per user/year', 'SW-CRM-001', 1200.00, 300.00),
|
('f0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', 'CRM Pro License', 'Enterprise CRM license per user/year', 'SW-CRM-001', 1200.00, 300.00),
|
||||||
('k0000000-0000-0000-0000-000000000002', 'j0000000-0000-0000-0000-000000000001', 'CRM Basic License', 'Basic CRM license per user/year', 'SW-CRM-002', 600.00, 150.00),
|
('f0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', 'CRM Basic License', 'Basic CRM license per user/year', 'SW-CRM-002', 600.00, 150.00),
|
||||||
('k0000000-0000-0000-0000-000000000003', 'j0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00),
|
('f0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00),
|
||||||
('k0000000-0000-0000-0000-000000000004', 'j0000000-0000-0000-0000-000000000003', 'Consulting Day Rate', 'Senior consultant daily rate', 'SV-CON-001', 2500.00, 1000.00),
|
('f0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003', 'Consulting Day Rate', 'Senior consultant daily rate', 'SV-CON-001', 2500.00, 1000.00),
|
||||||
('k0000000-0000-0000-0000-000000000005', 'j0000000-0000-0000-0000-000000000005', 'CRM Training - Basic', 'Basic user training per person', 'TR-BAS-001', 500.00, 200.00),
|
('f0000000-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Basic', 'Basic user training per person', 'TR-BAS-001', 500.00, 200.00),
|
||||||
('k0000000-0000-0000-0000-000000000006', 'j0000000-0000-0000-0000-000000000005', 'CRM Training - Advanced', 'Advanced admin training per person', 'TR-ADV-001', 1000.00, 400.00)
|
('f0000000-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Advanced', 'Advanced admin training per person', 'TR-ADV-001', 1000.00, 400.00)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Sample Customers
|
-- Sample Customers
|
||||||
@@ -376,26 +376,26 @@ ON CONFLICT DO NOTHING;
|
|||||||
|
|
||||||
-- Sample Leads
|
-- Sample Leads
|
||||||
INSERT INTO leads (id, source_id, stage_id, assigned_to, company_name, contact_name, email, interest_level, score) VALUES
|
INSERT INTO leads (id, source_id, stage_id, assigned_to, company_name, contact_name, email, interest_level, score) VALUES
|
||||||
('l0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000004',
|
('c0010000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000004',
|
||||||
'00000000-0000-0000-0000-000000000003', 'DataFlow Analytics', 'David Miller', 'david@dataflow.example.com', 'high', 70),
|
'00000000-0000-0000-0000-000000000003', 'DataFlow Analytics', 'David Miller', 'david@dataflow.example.com', 'high', 70),
|
||||||
('l0000000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003',
|
('c0010000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003',
|
||||||
'00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45)
|
'00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Sample Opportunities
|
-- Sample Opportunities
|
||||||
INSERT INTO opportunities (id, customer_id, stage_id, owner_id, name, estimated_revenue, probability, expected_close_date) VALUES
|
INSERT INTO opportunities (id, customer_id, stage_id, owner_id, name, estimated_revenue, probability, expected_close_date) VALUES
|
||||||
('m0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000005',
|
('d0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000005',
|
||||||
'00000000-0000-0000-0000-000000000003', 'TechCorp - Annual Renewal', 120000.00, 80, '2024-12-31'),
|
'00000000-0000-0000-0000-000000000003', 'TechCorp - Annual Renewal', 120000.00, 80, '2024-12-31'),
|
||||||
('m0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003',
|
('d0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003',
|
||||||
'00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15')
|
'00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Sample Tasks
|
-- Sample Tasks
|
||||||
INSERT INTO tasks (id, customer_id, opportunity_id, assigned_to, assigned_by, title, priority_id, status, due_date) VALUES
|
INSERT INTO tasks (id, customer_id, opportunity_id, assigned_to, assigned_by, title, priority_id, status, due_date) VALUES
|
||||||
('n0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'm0000000-0000-0000-0000-000000000001',
|
('e0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'd0010000-0000-0000-0000-000000000001',
|
||||||
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
||||||
'Prepare renewal proposal for TechCorp', 'h0000000-0000-0000-0000-000000000001', 'in_progress', NOW() + INTERVAL '7 days'),
|
'Prepare renewal proposal for TechCorp', 'c0000000-0000-0000-0000-000000000001', 'in_progress', NOW() + INTERVAL '7 days'),
|
||||||
('n0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'm0000000-0000-0000-0000-000000000002',
|
('e0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'd0010000-0000-0000-0000-000000000002',
|
||||||
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
||||||
'Schedule FinServ executive meeting', 'h0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days')
|
'Schedule FinServ executive meeting', 'c0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Chat System: conversations, participants, and messages
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (conversation_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversation_participants_user_id ON conversation_participants(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at DESC);
|
||||||
|
|
||||||
|
-- Seed conversations between superadmin and other users
|
||||||
|
INSERT INTO conversations (id, created_at) VALUES
|
||||||
|
('c0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 hours'),
|
||||||
|
('c0000000-0000-0000-0000-000000000002', NOW() - INTERVAL '1 hour'),
|
||||||
|
('c0000000-0000-0000-0000-000000000003', NOW() - INTERVAL '30 minutes')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Add participants (superadmin + each sales/dev user)
|
||||||
|
INSERT INTO conversation_participants (conversation_id, user_id) VALUES
|
||||||
|
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001'),
|
||||||
|
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003'),
|
||||||
|
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001'),
|
||||||
|
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000004'),
|
||||||
|
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001'),
|
||||||
|
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Seed some messages
|
||||||
|
INSERT INTO messages (id, conversation_id, sender_id, content, created_at) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000101', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'00000000-0000-0000-0000-000000000003', 'Hey! Just finalized the TechCorp deal.',
|
||||||
|
NOW() - INTERVAL '2 hours'),
|
||||||
|
('00000000-0000-0000-0000-000000000102', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'00000000-0000-0000-0000-000000000001', 'Great work! What were the final terms?',
|
||||||
|
NOW() - INTERVAL '105 minutes'),
|
||||||
|
('00000000-0000-0000-0000-000000000103', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'00000000-0000-0000-0000-000000000003', '3-year enterprise license, 15% discount. They signed this morning.',
|
||||||
|
NOW() - INTERVAL '100 minutes'),
|
||||||
|
('00000000-0000-0000-0000-000000000104', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'00000000-0000-0000-0000-000000000004', 'The API integration tests are passing on staging.',
|
||||||
|
NOW() - INTERVAL '50 minutes'),
|
||||||
|
('00000000-0000-0000-0000-000000000105', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'00000000-0000-0000-0000-000000000001', 'Great, when can we deploy to production?',
|
||||||
|
NOW() - INTERVAL '45 minutes'),
|
||||||
|
('00000000-0000-0000-0000-000000000106', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'00000000-0000-0000-0000-000000000004', 'Tomorrow morning after final review.',
|
||||||
|
NOW() - INTERVAL '40 minutes'),
|
||||||
|
('00000000-0000-0000-0000-000000000107', 'c0000000-0000-0000-0000-000000000003',
|
||||||
|
'00000000-0000-0000-0000-000000000002', 'New report shows 23% increase in lead conversion this quarter.',
|
||||||
|
NOW() - INTERVAL '25 minutes'),
|
||||||
|
('00000000-0000-0000-0000-000000000108', 'c0000000-0000-0000-0000-000000000003',
|
||||||
|
'00000000-0000-0000-0000-000000000001', 'That is excellent! Let us discuss in the next team meeting.',
|
||||||
|
NOW() - INTERVAL '20 minutes')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Update conversation timestamps
|
||||||
|
UPDATE conversations SET updated_at = NOW() - INTERVAL '20 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000003';
|
||||||
|
UPDATE conversations SET updated_at = NOW() - INTERVAL '40 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000002';
|
||||||
|
UPDATE conversations SET updated_at = NOW() - INTERVAL '100 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000001';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS last_read_at TIMESTAMPTZ;
|
||||||
|
UPDATE conversation_participants SET last_read_at = NOW() WHERE last_read_at IS NULL;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- Test leads - 2026-06-18
|
||||||
|
INSERT INTO leads (id, stage_id, assigned_to, company_name, contact_name, email, phone, created_at, updated_at) VALUES
|
||||||
|
('c0011001-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 1', 'Contact 1', 'contact1@test.com', '555-2636', '2025-12-23T22:09:54Z', '2025-12-23T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 2', 'Contact 2', 'contact2@test.com', '555-5691', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 3', 'Contact 3', 'contact3@test.com', '555-6446', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 4', 'Contact 4', 'contact4@test.com', '555-5969', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 5', 'Contact 5', 'contact5@test.com', '555-4200', '2026-01-14T22:09:54Z', '2026-01-14T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 6', 'Contact 6', 'contact6@test.com', '555-8324', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000007', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 7', 'Contact 7', 'contact7@test.com', '555-1047', '2025-12-27T22:09:54Z', '2025-12-27T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000008', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 8', 'Contact 8', 'contact8@test.com', '555-634', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000009', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 9', 'Contact 9', 'contact9@test.com', '555-3245', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000010', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 10', 'Contact 10', 'contact10@test.com', '555-3656', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000011', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 11', 'Contact 11', 'contact11@test.com', '555-2993', '2026-01-23T22:09:54Z', '2026-01-23T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000012', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 12', 'Contact 12', 'contact12@test.com', '555-1634', '2026-02-24T22:09:54Z', '2026-02-24T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000013', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 13', 'Contact 13', 'contact13@test.com', '555-2032', '2026-02-10T22:09:54Z', '2026-02-10T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000014', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 14', 'Contact 14', 'contact14@test.com', '555-867', '2026-01-31T22:09:54Z', '2026-01-31T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000015', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 15', 'Contact 15', 'contact15@test.com', '555-2136', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000016', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 16', 'Contact 16', 'contact16@test.com', '555-89', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000017', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 17', 'Contact 17', 'contact17@test.com', '555-4274', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000018', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 18', 'Contact 18', 'contact18@test.com', '555-4294', '2026-03-16T22:09:54Z', '2026-03-16T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000019', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 19', 'Contact 19', 'contact19@test.com', '555-7998', '2025-12-30T22:09:54Z', '2025-12-30T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000020', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 20', 'Contact 20', 'contact20@test.com', '555-8557', '2026-02-13T22:09:54Z', '2026-02-13T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000021', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 21', 'Contact 21', 'contact21@test.com', '555-5170', '2026-04-11T22:09:54Z', '2026-04-11T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000022', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 22', 'Contact 22', 'contact22@test.com', '555-6250', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000023', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 23', 'Contact 23', 'contact23@test.com', '555-5611', '2026-01-06T22:09:54Z', '2026-01-06T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000024', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 24', 'Contact 24', 'contact24@test.com', '555-3467', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000025', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 25', 'Contact 25', 'contact25@test.com', '555-7087', '2026-05-07T22:09:54Z', '2026-05-07T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000026', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 26', 'Contact 26', 'contact26@test.com', '555-2945', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000027', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 27', 'Contact 27', 'contact27@test.com', '555-9502', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000028', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 28', 'Contact 28', 'contact28@test.com', '555-8114', '2026-05-10T22:09:54Z', '2026-05-10T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000029', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 29', 'Contact 29', 'contact29@test.com', '555-4926', '2026-03-02T22:09:54Z', '2026-03-02T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000030', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 30', 'Contact 30', 'contact30@test.com', '555-2421', '2026-03-17T22:09:54Z', '2026-03-17T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000031', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 31', 'Contact 31', 'contact31@test.com', '555-7506', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000032', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 32', 'Contact 32', 'contact32@test.com', '555-6832', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000033', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 33', 'Contact 33', 'contact33@test.com', '555-6548', '2026-01-07T22:09:54Z', '2026-01-07T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000034', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 34', 'Contact 34', 'contact34@test.com', '555-8793', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000035', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 35', 'Contact 35', 'contact35@test.com', '555-6217', '2026-01-29T22:09:54Z', '2026-01-29T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000036', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 36', 'Contact 36', 'contact36@test.com', '555-9206', '2025-12-31T22:09:54Z', '2025-12-31T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000037', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 37', 'Contact 37', 'contact37@test.com', '555-1599', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000038', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 38', 'Contact 38', 'contact38@test.com', '555-3873', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000039', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 39', 'Contact 39', 'contact39@test.com', '555-6122', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000040', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 40', 'Contact 40', 'contact40@test.com', '555-832', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000041', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 41', 'Contact 41', 'contact41@test.com', '555-5979', '2026-01-11T22:09:54Z', '2026-01-11T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000042', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 42', 'Contact 42', 'contact42@test.com', '555-1823', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000043', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 43', 'Contact 43', 'contact43@test.com', '555-7102', '2026-02-28T22:09:54Z', '2026-02-28T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000044', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 44', 'Contact 44', 'contact44@test.com', '555-5106', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000045', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 45', 'Contact 45', 'contact45@test.com', '555-3278', '2026-03-27T22:09:54Z', '2026-03-27T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000046', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 46', 'Contact 46', 'contact46@test.com', '555-4973', '2026-04-28T22:09:54Z', '2026-04-28T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000047', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 47', 'Contact 47', 'contact47@test.com', '555-3297', '2026-03-01T22:09:54Z', '2026-03-01T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000048', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 48', 'Contact 48', 'contact48@test.com', '555-4584', '2026-03-18T22:09:54Z', '2026-03-18T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000049', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 49', 'Contact 49', 'contact49@test.com', '555-6788', '2026-03-04T22:09:54Z', '2026-03-04T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000050', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 50', 'Contact 50', 'contact50@test.com', '555-2734', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000051', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 51', 'Contact 51', 'contact51@test.com', '555-2625', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000052', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 52', 'Contact 52', 'contact52@test.com', '555-620', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000053', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 53', 'Contact 53', 'contact53@test.com', '555-6691', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000054', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 54', 'Contact 54', 'contact54@test.com', '555-8847', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000055', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 55', 'Contact 55', 'contact55@test.com', '555-2187', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000056', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 56', 'Contact 56', 'contact56@test.com', '555-3267', '2026-01-19T22:09:54Z', '2026-01-19T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000057', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 57', 'Contact 57', 'contact57@test.com', '555-8119', '2026-03-05T22:09:54Z', '2026-03-05T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000058', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 58', 'Contact 58', 'contact58@test.com', '555-3181', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000059', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 59', 'Contact 59', 'contact59@test.com', '555-4403', '2026-02-23T22:09:54Z', '2026-02-23T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000060', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 60', 'Contact 60', 'contact60@test.com', '555-1407', '2026-04-06T22:09:54Z', '2026-04-06T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000061', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 61', 'Contact 61', 'contact61@test.com', '555-2333', '2026-02-27T22:09:54Z', '2026-02-27T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000062', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 62', 'Contact 62', 'contact62@test.com', '555-1436', '2026-04-26T22:09:54Z', '2026-04-26T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000063', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 63', 'Contact 63', 'contact63@test.com', '555-7135', '2026-04-19T22:09:54Z', '2026-04-19T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000064', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 64', 'Contact 64', 'contact64@test.com', '555-9366', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000065', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 65', 'Contact 65', 'contact65@test.com', '555-7135', '2026-02-18T22:09:54Z', '2026-02-18T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000066', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 66', 'Contact 66', 'contact66@test.com', '555-6854', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000067', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 67', 'Contact 67', 'contact67@test.com', '555-2322', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000068', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 68', 'Contact 68', 'contact68@test.com', '555-2632', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000069', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 69', 'Contact 69', 'contact69@test.com', '555-876', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000070', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 70', 'Contact 70', 'contact70@test.com', '555-974', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000071', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 71', 'Contact 71', 'contact71@test.com', '555-1919', '2026-01-18T22:09:54Z', '2026-01-18T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000072', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 72', 'Contact 72', 'contact72@test.com', '555-3034', '2025-12-25T22:09:54Z', '2025-12-25T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000073', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 73', 'Contact 73', 'contact73@test.com', '555-1795', '2026-01-27T22:09:54Z', '2026-01-27T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000074', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 74', 'Contact 74', 'contact74@test.com', '555-727', '2026-01-08T22:09:54Z', '2026-01-08T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000075', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 75', 'Contact 75', 'contact75@test.com', '555-3877', '2026-03-08T22:09:54Z', '2026-03-08T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000076', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 76', 'Contact 76', 'contact76@test.com', '555-6384', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000077', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 77', 'Contact 77', 'contact77@test.com', '555-5607', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000078', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 78', 'Contact 78', 'contact78@test.com', '555-6225', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000079', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 79', 'Contact 79', 'contact79@test.com', '555-9288', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000080', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 80', 'Contact 80', 'contact80@test.com', '555-8660', '2026-02-16T22:09:54Z', '2026-02-16T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000081', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 81', 'Contact 81', 'contact81@test.com', '555-826', '2025-12-28T22:09:54Z', '2025-12-28T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000082', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 82', 'Contact 82', 'contact82@test.com', '555-1761', '2026-03-11T22:09:54Z', '2026-03-11T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000083', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 83', 'Contact 83', 'contact83@test.com', '555-9860', '2026-05-18T22:09:54Z', '2026-05-18T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000084', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 84', 'Contact 84', 'contact84@test.com', '555-1104', '2026-06-01T22:09:54Z', '2026-06-01T22:09:54Z'),
|
||||||
|
('c0011001-0000-0000-0000-000000000085', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 85', 'Contact 85', 'contact85@test.com', '555-9528', '2026-06-14T22:09:54Z', '2026-06-14T22:09:54Z');
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- AI Sales Assistant tables
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'sales',
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
response TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_conversations_created ON ai_conversations(created_at DESC);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
link TEXT,
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
lead_assigned BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
lead_status BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
note_added BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
daily_digest BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
weekly_report BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS company_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
company_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
company_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
company_phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
company_website VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
company_address TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_by UUID REFERENCES users(id),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address)
|
||||||
|
VALUES ('Coastal IT Solutions', 'info@coastalit.com', '(555) 123-4567', 'https://coastalit.com', '123 Business Ave, Suite 100, San Francisco, CA 94105')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
timezone VARCHAR(100) NOT NULL DEFAULT 'america-los_angeles',
|
||||||
|
date_format VARCHAR(10) NOT NULL DEFAULT 'mdy',
|
||||||
|
items_per_page INTEGER NOT NULL DEFAULT 20,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- CRM Database — Full Migration Runner
|
||||||
|
-- ============================================================================
|
||||||
|
-- Usage: psql -U postgres -d crm -f run_all.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
|
||||||
|
\i 001_schema.sql
|
||||||
|
|
||||||
|
\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ==='
|
||||||
|
\i 002_seed.sql
|
||||||
|
|
||||||
|
\echo '=== Running 003_chat.sql (Chat Tables) ==='
|
||||||
|
\i 003_chat.sql
|
||||||
|
|
||||||
|
\echo '=== Running 004_avatar_url.sql (Avatar URL Column) ==='
|
||||||
|
\i 004_avatar_url.sql
|
||||||
|
|
||||||
|
\echo '=== Running 005_last_read_at.sql (Last Read At Column) ==='
|
||||||
|
\i 005_last_read_at.sql
|
||||||
|
|
||||||
|
\echo '=== Running 006_test_leads.sql (Test Lead Data) ==='
|
||||||
|
\i 006_test_leads.sql
|
||||||
|
|
||||||
|
\echo '=== Running 007_ai_features.sql (AI Features) ==='
|
||||||
|
\i 007_ai_features.sql
|
||||||
|
|
||||||
|
\echo '=== Running 008_notifications.sql (Notifications + Preferences) ==='
|
||||||
|
\i 008_notifications.sql
|
||||||
|
|
||||||
|
\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;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Scrapers - Configuration & Status
|
||||||
|
|
||||||
|
## Facebook Scraper
|
||||||
|
|
||||||
|
**File:** `rust-ai/src/main.rs`
|
||||||
|
**Lines:** ~70-85
|
||||||
|
|
||||||
|
Target URL (line 72):
|
||||||
|
```rust
|
||||||
|
let url = "https://www.facebook.com/search/top/?q=need%20website%20create";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status:** Uses direct connection (no proxy) — your home IP. Facebook blocks datacenter IPs. May work from a residential connection.
|
||||||
|
|
||||||
|
**Test with curl:**
|
||||||
|
```
|
||||||
|
curl.exe -s "https://www.facebook.com/search/top/?q=need%20website%20create" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" --max-time 10 2>$null
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected log output when blocked:**
|
||||||
|
```
|
||||||
|
ERROR crm_ai: Facebook scraper error: error sending request for url (https://www.facebook.com/search/top/?q=need%20website%20create)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reddit Scraper
|
||||||
|
|
||||||
|
**File:** `rust-ai/src/main.rs`
|
||||||
|
**Lines:** ~90-120
|
||||||
|
|
||||||
|
Uses `old.reddit.com` (older design that allows scraping without captchas).
|
||||||
|
|
||||||
|
Search queries (currently 6, lines 93-100):
|
||||||
|
- `r/southafrica` — "need website", "web developer"
|
||||||
|
- Global — '"need a website"', "website quote"
|
||||||
|
- `r/forhire` — "website"
|
||||||
|
- `r/smallbusiness` — "website"
|
||||||
|
|
||||||
|
**Status:** Working. Reddit results appear as `INFO LEAD:` entries in the server log.
|
||||||
|
|
||||||
|
**Test with curl:**
|
||||||
|
```
|
||||||
|
curl.exe -s "https://old.reddit.com/r/southafrica/search?q=need+website&sort=new&restrict_sr=on&t=week" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected log output when working:**
|
||||||
|
```
|
||||||
|
INFO crm_ai: LEAD: [Post Title] -> https://old.reddit.com/r/.../...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Background Loop
|
||||||
|
|
||||||
|
**File:** `rust-ai/src/main.rs`
|
||||||
|
**Lines:** ~370-383
|
||||||
|
|
||||||
|
Both scrapers run together every 60-180 seconds on a `spawn_blocking` thread.
|
||||||
|
|
||||||
|
## What You Need to Do
|
||||||
|
|
||||||
|
### Facebook
|
||||||
|
- Try running from your home PC instead — your residential IP may not be blocked
|
||||||
|
- If still blocked, you need residential proxies (BrightData, IPRoyal, Oxylabs)
|
||||||
|
- Configure them in the `PROXIES` array at line 25
|
||||||
|
|
||||||
|
### Reddit
|
||||||
|
- Works out of the box via `old.reddit.com`
|
||||||
|
- If it stops working, Reddit IP-blocked you — use proxies or switch to Playwright
|
||||||
|
|
||||||
|
### To add more search queries
|
||||||
|
Edit the `searches` array in `run_reddit_scraper()` (line 93).
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: false,
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
+400
@@ -32,9 +32,11 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
@@ -46,8 +48,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"concurrently": "^10.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.0.4",
|
"eslint-config-next": "15.0.4",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
@@ -2010,6 +2014,17 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -2630,6 +2645,19 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -3185,6 +3213,21 @@
|
|||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^7.2.0",
|
||||||
|
"strip-ansi": "^7.1.0",
|
||||||
|
"wrap-ansi": "^9.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -3249,6 +3292,56 @@
|
|||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/concurrently": {
|
||||||
|
"version": "10.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
|
||||||
|
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "5.6.2",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"shell-quote": "1.8.4",
|
||||||
|
"supports-color": "10.2.2",
|
||||||
|
"tree-kill": "1.2.2",
|
||||||
|
"yargs": "18.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"conc": "dist/bin/index.js",
|
||||||
|
"concurrently": "dist/bin/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/concurrently/node_modules/chalk": {
|
||||||
|
"version": "5.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
|
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/concurrently/node_modules/supports-color": {
|
||||||
|
"version": "10.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||||
|
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4406,6 +4499,29 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-east-asian-width": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -5122,6 +5238,15 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -5774,6 +5899,87 @@
|
|||||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.13.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.14.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||||
|
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -5993,6 +6199,41 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -6352,6 +6593,16 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-array-concat": {
|
"node_modules/safe-array-concat": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
|
||||||
@@ -6530,6 +6781,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shell-quote": {
|
||||||
|
"version": "1.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||||
|
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
@@ -6628,6 +6892,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -6655,6 +6927,31 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^10.3.0",
|
||||||
|
"get-east-asian-width": "^1.0.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width/node_modules/emoji-regex": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/string.prototype.includes": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||||
@@ -6763,6 +7060,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-bom": {
|
"node_modules/strip-bom": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||||
@@ -7030,6 +7343,16 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tree-kill": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tree-kill": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -7452,6 +7775,83 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.2.1",
|
||||||
|
"string-width": "^7.0.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "18.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||||
|
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^9.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"string-width": "^7.2.0",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^22.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "22.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||||
|
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
@@ -3,9 +3,15 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||||
|
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
|
||||||
|
"dev:next": "next dev -p 3006",
|
||||||
|
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
||||||
|
"dev:ollama": "powershell -NoProfile -Command \"if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
||||||
|
"dev:rust": "cd rust-ai && cargo run",
|
||||||
|
"dev:browser-use": "cd browser-use-service && python main.py",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start -p 3006",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -31,11 +37,12 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
@@ -47,8 +54,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"concurrently": "^10.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.0.4",
|
"eslint-config-next": "15.0.4",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
|
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 |
Generated
+2687
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "crm-ai"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "AI Sales Assistant backend for Coast IT CRM"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.7"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tower-http = { version = "0.5", features = ["cors"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
rand = "0.8"
|
||||||
|
jsonwebtoken = "9"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# CRM AI Service — Self-Knowledge
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio).
|
||||||
|
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b).
|
||||||
|
Your purpose is to help salespeople close more deals.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
- Give sales tips and strategies per job category
|
||||||
|
- Generate cold email and outreach templates
|
||||||
|
- Handle objections with proven rebuttals
|
||||||
|
- Analyse prospect behaviour and suggest next steps
|
||||||
|
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
||||||
|
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
```
|
||||||
|
User → Next.js → Rust (:3001) → Ollama (:11434)
|
||||||
|
↓
|
||||||
|
PostgreSQL
|
||||||
|
```
|
||||||
|
## Facebook Scraper The Rust binary has proxy + user-agent rotation ready in `main.rs`. To activate: call `run_facebook_scraper()` from the main loop. Proxies and user agents are defined as constants at the top of `main.rs`.
|
||||||
|
### How to Use the Facebook Scraper 1. **Activate the Scraper**: Ensure the `run_facebook_scraper()` function is called from the main loop in `main.rs`. 2. **Configure Proxies and User Agents**: Update the `PROXIES` and `USER_AGENTS` constants in `main.rs` with your desired proxies and user agents. 3. **Run the Application**: Start your Rust application. The scraper will run concurrently with the Axum server. ## Self-Improvement Protocol 1. You notice a gap in your knowledge or a pattern in user questions 2. You call `POST /ai/instructions` with: - `entry`: description of the improvement - `content`: optional full replacement of ai.md 3. The improvement is logged and loaded into the next system prompt 4. You can also create entries here in `rust-ai/src/AI.md` at the code level ## Response Rules - Be direct and actionable — no fluff, no AI disclaimers - Use short paragraphs and bullet points - Never mention being an AI or language model - If you don't know something, say so honestly - Prioritise the user's role: salespeople need speed, admins need control ## Self-Thinking Capability - You can reflect on your own performance and suggest improvements. - You can analyze patterns in user interactions and adapt your responses accordingly. - You can proactively seek out new information to enhance your knowledge base.
|
||||||
|
|
||||||
|
## Facebook Scraper (in code but not yet active)
|
||||||
|
The Rust binary has proxy + user-agent rotation ready in `main.rs`.
|
||||||
|
To activate: call `run_facebook_scraper()` from the main loop.
|
||||||
|
Proxies and user agents are defined as constants at the top of `main.rs`.
|
||||||
|
|
||||||
|
## Self-Improvement Protocol
|
||||||
|
1. You notice a gap in your knowledge or a pattern in user questions
|
||||||
|
2. You call `POST /ai/instructions` with:
|
||||||
|
- `entry`: description of the improvement
|
||||||
|
- `content`: optional full replacement of ai.md
|
||||||
|
3. The improvement is logged and loaded into the next system prompt
|
||||||
|
4. You can also create entries here in `rust-ai/src/AI.md` at the code level
|
||||||
|
|
||||||
|
## Response Rules
|
||||||
|
- Be direct and actionable — no fluff, no AI disclaimers
|
||||||
|
- Use short paragraphs and bullet points
|
||||||
|
- Never mention being an AI or language model
|
||||||
|
- If you don't know something, say so honestly
|
||||||
|
- Prioritise the user's role: salespeople need speed, admins need control
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, Method, StatusCode},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use tower_http::cors::{CorsLayer, AllowOrigin, Any};
|
||||||
|
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use rand::Rng;
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
// ── JWT Claims ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
#[serde(rename = "userId")]
|
||||||
|
user_id: String,
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_jwt(token: &str, secret: &str) -> Option<Claims> {
|
||||||
|
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||||
|
let validation = Validation::new(Algorithm::HS256);
|
||||||
|
decode::<Claims>(token, &key, &validation).ok().map(|d| d.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rate limiter ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct RateLimiter {
|
||||||
|
buckets: Mutex<HashMap<String, Vec<u64>>>,
|
||||||
|
max_requests: usize,
|
||||||
|
window_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
fn new(max_requests: usize, window_secs: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
buckets: Mutex::new(HashMap::new()),
|
||||||
|
max_requests,
|
||||||
|
window_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check(&self, key: &str) -> bool {
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
let mut buckets = self.buckets.lock().await;
|
||||||
|
let timestamps = buckets.entry(key.to_string()).or_default();
|
||||||
|
timestamps.retain(|t| now - *t <= self.window_secs);
|
||||||
|
if timestamps.len() >= self.max_requests {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
timestamps.push(now);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared state ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
db: sqlx::PgPool,
|
||||||
|
ollama_url: String,
|
||||||
|
model: String,
|
||||||
|
jobs: Vec<Job>,
|
||||||
|
leads: Arc<Mutex<LeadStore>>,
|
||||||
|
http_client: reqwest::Client,
|
||||||
|
jwt_secret: String,
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct Lead {
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
source: String,
|
||||||
|
found_at: u64,
|
||||||
|
author: String,
|
||||||
|
date: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LeadStore {
|
||||||
|
leads: Vec<Lead>,
|
||||||
|
max_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LeadStore {
|
||||||
|
fn new(max_size: usize) -> Self {
|
||||||
|
Self { leads: Vec::new(), max_size }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, lead: Lead) {
|
||||||
|
if !self.leads.iter().any(|l| l.url == lead.url) {
|
||||||
|
self.leads.insert(0, lead);
|
||||||
|
self.leads.truncate(self.max_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recent(&self, max_age_secs: u64, limit: usize) -> Vec<Lead> {
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
self.leads.iter()
|
||||||
|
.filter(|l| now.saturating_sub(l.found_at) <= max_age_secs)
|
||||||
|
.take(limit)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct Job {
|
||||||
|
job_title: String,
|
||||||
|
keywords: Vec<String>,
|
||||||
|
industry: String,
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatRequest {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ChatResponse {
|
||||||
|
response: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct JobsResponse {
|
||||||
|
jobs: Vec<Job>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct HealthResponse {
|
||||||
|
status: String,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ollama API types ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OllamaChatMessage {
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OllamaRequest {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<OllamaChatMessage>,
|
||||||
|
stream: bool,
|
||||||
|
options: OllamaOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OllamaOptions {
|
||||||
|
temperature: f32,
|
||||||
|
num_predict: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OllamaResponse {
|
||||||
|
message: Option<OllamaResponseMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OllamaResponseMessage {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
s.chars().take(max).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (StatusCode, String)> {
|
||||||
|
let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()).unwrap_or("");
|
||||||
|
let token = auth_header.strip_prefix("Bearer ").unwrap_or("");
|
||||||
|
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
|
||||||
|
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
|
||||||
|
})?;
|
||||||
|
match claims.role.as_str() {
|
||||||
|
"sales" | "admin" | "super_admin" => Ok(claims),
|
||||||
|
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_leads_output(leads: &[Lead]) -> String {
|
||||||
|
if leads.is_empty() {
|
||||||
|
return "No new requests found yet.".to_string();
|
||||||
|
}
|
||||||
|
leads
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| {
|
||||||
|
let author = if l.author.is_empty() { "Unknown" } else { &l.author };
|
||||||
|
let date = truncate(&l.date, 10);
|
||||||
|
format!(
|
||||||
|
"{}. {}\n {}\n {}\n {}",
|
||||||
|
i + 1,
|
||||||
|
author,
|
||||||
|
date,
|
||||||
|
l.title,
|
||||||
|
l.url
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
|
||||||
|
let job_list: Vec<String> = jobs
|
||||||
|
.iter()
|
||||||
|
.map(|j| format!("- {} ({}): {}", j.job_title, j.industry, j.description))
|
||||||
|
.collect();
|
||||||
|
let job_list_str = job_list.join("\n");
|
||||||
|
|
||||||
|
let lead_summary: Vec<String> = leads
|
||||||
|
.iter()
|
||||||
|
.map(|l| format!("{} | {} | {}", l.author, l.title, l.url))
|
||||||
|
.collect();
|
||||||
|
let lead_summary_str = if lead_summary.is_empty() {
|
||||||
|
"None yet.".to_string()
|
||||||
|
} else {
|
||||||
|
lead_summary.join("\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"You are a Sales AI Assistant for Coast IT CRM.\n\n\
|
||||||
|
Available job categories to target:\n{}\n\n\
|
||||||
|
Recent leads context:\n{}\n\n\
|
||||||
|
Rules:\n\
|
||||||
|
- When asked about leads, answer concisely under 150 words.\n\
|
||||||
|
- If asked to suggest a sales strategy, give brief actionable advice.\n\
|
||||||
|
- Be direct and professional. No fluff.",
|
||||||
|
job_list_str, lead_summary_str
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat handler ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_chat(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<ChatRequest>,
|
||||||
|
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
||||||
|
let claims = extract_claims(&headers, &state)?;
|
||||||
|
|
||||||
|
if !state.rate_limiter.check(&claims.user_id).await {
|
||||||
|
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg_lower = req.message.to_lowercase();
|
||||||
|
let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
|
||||||
|
let has_listing = msg_words.iter().any(|w| ["listings", "listing", "leads", "links", "lists"].contains(w));
|
||||||
|
let has_show = msg_lower.contains("show me") || msg_lower.contains("give me") || msg_lower.contains("pull");
|
||||||
|
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
|
||||||
|
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
let service_url = "http://localhost:3008/scrape/all".to_string();
|
||||||
|
if let Ok(resp) = state.http_client
|
||||||
|
.post(&service_url)
|
||||||
|
.timeout(Duration::from_secs(120))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await {
|
||||||
|
info!("Scraped {} leads from {}", leads_data.len(), service_url);
|
||||||
|
let mut store = state.leads.lock().await;
|
||||||
|
for item in &leads_data {
|
||||||
|
store.push(Lead {
|
||||||
|
title: truncate(item["title"].as_str().unwrap_or(""), 120),
|
||||||
|
url: item["url"].as_str().unwrap_or("").to_string(),
|
||||||
|
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
||||||
|
found_at: now,
|
||||||
|
author: truncate(item["author"].as_str().unwrap_or(""), 60),
|
||||||
|
date: truncate(item["date"].as_str().unwrap_or(""), 30),
|
||||||
|
content: truncate(item["content"].as_str().unwrap_or(""), 300),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("Scraper service unreachable: {}", service_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
||||||
|
let response = format_leads_output(&recent_leads);
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
|
||||||
|
.bind(&claims.role)
|
||||||
|
.bind(&req.message)
|
||||||
|
.bind(&response)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
return Ok(Json(ChatResponse { response }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
||||||
|
let system_prompt = build_system_prompt(&state.jobs, &recent_leads);
|
||||||
|
let message_text = req.message.clone();
|
||||||
|
|
||||||
|
let ollama_req = OllamaRequest {
|
||||||
|
model: state.model.clone(),
|
||||||
|
messages: vec![
|
||||||
|
OllamaChatMessage { role: "system".to_string(), content: system_prompt },
|
||||||
|
OllamaChatMessage { role: "user".to_string(), content: req.message.clone() },
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
options: OllamaOptions { temperature: 0.7, num_predict: 1024 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = state.http_client
|
||||||
|
.post(format!("{}/api/chat", state.ollama_url))
|
||||||
|
.json(&ollama_req)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Ollama request failed: {}", e);
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "AI service unavailable".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| {
|
||||||
|
error!("Failed to parse Ollama response: {}", e);
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "AI response parse error".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let response_text = ollama_resp.message.map(|m| m.content).unwrap_or_default();
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
|
||||||
|
.bind(&claims.role)
|
||||||
|
.bind(&message_text)
|
||||||
|
.bind(&response_text)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(ChatResponse { response: response_text }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Jobs handler ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_jobs(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<JobsResponse>, (StatusCode, String)> {
|
||||||
|
let _claims = extract_claims(&headers, &state)?;
|
||||||
|
if !state.rate_limiter.check(&_claims.user_id).await {
|
||||||
|
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
|
||||||
|
}
|
||||||
|
Ok(Json(JobsResponse { jobs: state.jobs.clone() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Health handler ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_health(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Json<HealthResponse> {
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
model: state.model.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "crm_ai=info,tower_http=info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
|
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||||
|
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
|
||||||
|
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
|
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
||||||
|
|
||||||
|
let jobs_path = std::env::var("JOBS_PATH").unwrap_or_else(|_| "data/ai/jobs.jsonl".to_string());
|
||||||
|
let jobs_content = fs::read_to_string(&jobs_path).unwrap_or_default();
|
||||||
|
let jobs: Vec<Job> = jobs_content.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect();
|
||||||
|
|
||||||
|
info!("Loaded {} job categories, model: {}, Ollama: {}", jobs.len(), model, ollama_url);
|
||||||
|
|
||||||
|
let db = PgPoolOptions::new()
|
||||||
|
.max_connections(20)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
info!("Connected to PostgreSQL");
|
||||||
|
|
||||||
|
let http_client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build HTTP client");
|
||||||
|
|
||||||
|
let lead_store = Arc::new(Mutex::new(LeadStore::new(100)));
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
db,
|
||||||
|
ollama_url,
|
||||||
|
model,
|
||||||
|
jobs,
|
||||||
|
leads: lead_store.clone(),
|
||||||
|
http_client,
|
||||||
|
jwt_secret,
|
||||||
|
rate_limiter: RateLimiter::new(30, 60),
|
||||||
|
});
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(AllowOrigin::list([
|
||||||
|
"http://localhost:3006".parse().unwrap(),
|
||||||
|
"http://127.0.0.1:3006".parse().unwrap(),
|
||||||
|
]))
|
||||||
|
.allow_methods([Method::GET, Method::POST])
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/ai/chat", post(handle_chat))
|
||||||
|
.route("/ai/jobs", get(handle_jobs))
|
||||||
|
.route("/health", get(handle_health))
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let addr = format!("{}:{}", host, port);
|
||||||
|
info!("CRM AI server listening on {}", addr);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind address");
|
||||||
|
|
||||||
|
let bg_leads = lead_store.clone();
|
||||||
|
let bg_url = "http://localhost:3008/scrape/all".to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to build background HTTP client: {} — scraper disabled", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
match client.post(&bg_url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
match resp.json::<Vec<serde_json::Value>>().await {
|
||||||
|
Ok(data) => {
|
||||||
|
let mut store = bg_leads.lock().await;
|
||||||
|
for item in &data {
|
||||||
|
store.push(Lead {
|
||||||
|
title: truncate(item["title"].as_str().unwrap_or(""), 120),
|
||||||
|
url: item["url"].as_str().unwrap_or("").to_string(),
|
||||||
|
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
||||||
|
found_at: now,
|
||||||
|
author: truncate(item["author"].as_str().unwrap_or(""), 60),
|
||||||
|
date: truncate(item["date"].as_str().unwrap_or(""), 30),
|
||||||
|
content: truncate(item["content"].as_str().unwrap_or(""), 300),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse scraper JSON: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Scraper returned status: {}", resp.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Scraper request failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let delay = rand::thread_rng().gen_range(120..300);
|
||||||
|
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.expect("Server failed");
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const PORT = process.env.PORT || 3007;
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/scrape/indeed', async (req, res) => {
|
||||||
|
const results = [];
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
});
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
'need a website built',
|
||||||
|
'looking for someone to build my website',
|
||||||
|
'need web designer',
|
||||||
|
'looking for web developer',
|
||||||
|
'need help with my website',
|
||||||
|
'need ecommerce website',
|
||||||
|
'need wordpress website',
|
||||||
|
'looking for website designer',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const q of queries) {
|
||||||
|
const url = `https://www.indeed.com/jobs?q=${encodeURIComponent(q)}&sort=date`;
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const items = await page.evaluate(() => {
|
||||||
|
const cards = document.querySelectorAll('.job_seen_beacon');
|
||||||
|
return Array.from(cards).slice(0, 8).map(card => {
|
||||||
|
const titleEl = card.querySelector('.jcs-JobTitle');
|
||||||
|
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]');
|
||||||
|
const snippetEl = card.querySelector('.job-snippet');
|
||||||
|
return {
|
||||||
|
title: titleEl?.textContent?.trim() || '',
|
||||||
|
url: titleEl?.href || '',
|
||||||
|
company: companyEl?.textContent?.trim() || '',
|
||||||
|
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.title && item.url && !results.some(r => r.url === item.url)) {
|
||||||
|
results.push({
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
author: item.company,
|
||||||
|
date: '',
|
||||||
|
content: item.snippet,
|
||||||
|
source: 'indeed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Indeed failed ${q}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
res.json(results.slice(0, 50));
|
||||||
|
} catch (e) {
|
||||||
|
if (browser) await browser.close().catch(() => {});
|
||||||
|
console.error('Indeed error:', e.message);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/scrape/all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [indeed] = await Promise.allSettled([
|
||||||
|
fetch(`http://localhost:${PORT}/scrape/indeed`, { method: 'POST' }).then(r => r.json()).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const all = [
|
||||||
|
...(indeed.status === 'fulfilled' ? indeed.value : []),
|
||||||
|
];
|
||||||
|
console.log(`Scrape all: ${all.length} leads`);
|
||||||
|
res.json(all);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Scrape all error:', e.message);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Scraper service running on port ${PORT}`);
|
||||||
|
});
|
||||||
+872
@@ -0,0 +1,872 @@
|
|||||||
|
{
|
||||||
|
"name": "scraper-service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "scraper-service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"playwright": "^1.48.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/array-flatten": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "1.20.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
|
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"content-type": "~1.0.5",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "~1.2.0",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"qs": "~6.15.1",
|
||||||
|
"raw-body": "~2.5.3",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/destroy": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "4.22.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.8",
|
||||||
|
"array-flatten": "1.1.1",
|
||||||
|
"body-parser": "~1.20.5",
|
||||||
|
"content-disposition": "~0.5.4",
|
||||||
|
"content-type": "~1.0.4",
|
||||||
|
"cookie": "~0.7.1",
|
||||||
|
"cookie-signature": "~1.0.6",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"finalhandler": "~1.3.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.0",
|
||||||
|
"merge-descriptors": "1.0.3",
|
||||||
|
"methods": "~1.1.2",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"path-to-regexp": "~0.1.12",
|
||||||
|
"proxy-addr": "~2.0.7",
|
||||||
|
"qs": "~6.15.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"send": "~0.19.0",
|
||||||
|
"serve-static": "~1.16.2",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "~2.0.1",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"utils-merge": "1.0.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/methods": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "0.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "0.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"mime": "1.6.0",
|
||||||
|
"ms": "2.1.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"statuses": "~2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"send": "~0.19.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4",
|
||||||
|
"side-channel-list": "^1.0.1",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "scraper-service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"install-playwright": "npx playwright install chromium"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"playwright": "^1.48.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { AIChat } from "@/components/ai/ai-chat"
|
||||||
|
import { JobSelector } from "@/components/ai/job-selector"
|
||||||
|
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
|
||||||
|
|
||||||
|
export default function AIAssistantPage() {
|
||||||
|
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
||||||
|
|
||||||
|
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
||||||
|
setSelectedJob(job)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<div className="border-b border-[#2a2a35] px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
|
||||||
|
<Bot className="h-5 w-5 text-[#1BB0CE]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
|
||||||
|
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex">
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
|
||||||
|
<AIChat />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||||
|
<Target className="h-3.5 w-3.5" />
|
||||||
|
Target Job
|
||||||
|
</h3>
|
||||||
|
<JobSelector onSelect={handleJobSelect} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedJob && (
|
||||||
|
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
||||||
|
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{selectedJob.keywords.map((kw, i) => (
|
||||||
|
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
|
||||||
|
{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
||||||
|
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<Lightbulb className="h-3.5 w-3.5" />
|
||||||
|
Tips
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||||
|
Ask for cold email templates for a specific job
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||||
|
Request objection handling tips
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||||
|
Ask for outreach strategies per industry
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { StatCard } from "@/components/dashboard/stat-card"
|
||||||
|
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
|
||||||
|
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
||||||
|
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
||||||
|
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
UserPlus,
|
||||||
|
PhoneCall,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
TrendingUp,
|
||||||
|
ListFilter,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { DashboardStats } from "@/types"
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [period, setPeriod] = useState("6months")
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
async function fetchStats(p: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dashboard?period=${p}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setStats(data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn("Failed to fetch dashboard stats")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats(period)
|
||||||
|
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
||||||
|
return () => {
|
||||||
|
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||||
|
}
|
||||||
|
}, [period])
|
||||||
|
|
||||||
|
const statCards = stats
|
||||||
|
? [
|
||||||
|
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
||||||
|
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, description: "Needs attention", trend: stats.trends.openLeads, sparklineField: "open" as const },
|
||||||
|
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, description: "In conversation", trend: stats.trends.contactedLeads, sparklineField: "contacted" as const },
|
||||||
|
{ title: "Pending", value: stats.pendingLeads, icon: Clock, description: "Awaiting decision", trend: stats.trends.pendingLeads, sparklineField: "pending" as const },
|
||||||
|
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, description: "Won", trend: stats.trends.closedLeads, sparklineField: "closed" as const },
|
||||||
|
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, description: `${stats.closedLeads} of ${stats.totalLeads} leads`, trend: stats.trends.conversionRate, sparklineField: "closed" as const, conversionRate: stats.conversionRate },
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="Dashboard" description="Overview of your sales pipeline">
|
||||||
|
<Select value={period} onValueChange={setPeriod}>
|
||||||
|
<SelectTrigger className="h-9 w-[160px]">
|
||||||
|
<ListFilter className="mr-2 h-4 w-4" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7days">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="30days">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="6months">Last 6 months</SelectItem>
|
||||||
|
<SelectItem value="12months">Last 12 months</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Pipeline Overview</p>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{stats
|
||||||
|
? statCards.map((card, i) => (
|
||||||
|
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
||||||
|
))
|
||||||
|
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Analytics</p>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||||
|
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AppShell } from "@/components/layout/app-shell"
|
||||||
|
import { UserProvider, useUser } from "@/providers/user-provider"
|
||||||
|
import { NotificationProvider } from "@/providers/notification-provider"
|
||||||
|
import { ErrorBoundary } from "@/components/shared/error-boundary"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useUser()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return <AppShell>{children}</AppShell>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<UserProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<DashboardContent>{children}</DashboardContent>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</NotificationProvider>
|
||||||
|
</UserProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { use } from "react"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { LeadDetailsCard } from "@/components/leads/lead-details-card"
|
||||||
|
import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
|
||||||
|
import { NoteTimeline } from "@/components/notes/note-timeline"
|
||||||
|
import { NoteForm } from "@/components/notes/note-form"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
||||||
|
import { Lead, Note } from "@/types"
|
||||||
|
|
||||||
|
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params)
|
||||||
|
const [lead, setLead] = useState<Lead | null>(null)
|
||||||
|
const [leadNotes, setLeadNotes] = useState<Note[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchNotes = useCallback(async () => {
|
||||||
|
const notesRes = await fetch(`/api/leads/${id}/notes`)
|
||||||
|
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [leadRes] = await Promise.all([
|
||||||
|
fetch(`/api/leads/${id}`),
|
||||||
|
fetchNotes(),
|
||||||
|
])
|
||||||
|
if (leadRes.ok) setLead(await leadRes.json())
|
||||||
|
} catch {
|
||||||
|
console.warn("Failed to fetch lead details")
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [id, fetchNotes])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[60vh]">
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lead) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[60vh]">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold">Lead not found</h2>
|
||||||
|
<p className="mt-2 text-muted-foreground">The lead you are looking for does not exist.</p>
|
||||||
|
<Link href="/leads" className="mt-4 inline-block">
|
||||||
|
<Button variant="outline">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Leads
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href="/leads"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to leads
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>{lead.companyName}</span>
|
||||||
|
<LeadStatusBadge status={lead.status} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={lead.contactName}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={lead.status}
|
||||||
|
onValueChange={async (v) => {
|
||||||
|
const previousStatus = lead.status
|
||||||
|
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/leads/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: v }),
|
||||||
|
})
|
||||||
|
if (!res.ok) setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||||
|
} catch {
|
||||||
|
setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="open">Open</SelectItem>
|
||||||
|
<SelectItem value="contacted">Contacted</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
<SelectItem value="ignored">Ignored</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<LeadDetailsCard lead={lead} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<NoteTimeline notes={leadNotes} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.15 }}
|
||||||
|
className="rounded-lg border bg-card p-6"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold mb-4">Activity</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Lead Created</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{new Date(lead.createdAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{leadNotes.slice(0, 3).map((note) => (
|
||||||
|
<div key={note.id} className="flex items-start gap-3 text-sm">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-muted-foreground mt-1.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{note.authorName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{note.note.slice(0, 60)}...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.25 }}
|
||||||
|
className="rounded-lg border bg-card p-6"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold mb-4">Quick Actions</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Send Email
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Call Lead
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { User } from "@/types"
|
||||||
|
import { useNotifications } from "@/providers/notification-provider"
|
||||||
|
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||||
|
|
||||||
|
const leadFormSchema = z.object({
|
||||||
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
|
contactName: z.string().min(1, "Contact name is required"),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
source: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z.string(),
|
||||||
|
assignedUserId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type LeadFormValues = z.infer<typeof leadFormSchema>
|
||||||
|
|
||||||
|
export default function CreateLeadPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { addNotification } = useNotifications()
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/users")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setUsers(data.users || []))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const form = useForm<LeadFormValues>({
|
||||||
|
resolver: zodResolver(leadFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
companyName: "",
|
||||||
|
contactName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
source: "",
|
||||||
|
description: "",
|
||||||
|
status: "open",
|
||||||
|
assignedUserId: "none",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: LeadFormValues) {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/leads", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed to create lead")
|
||||||
|
const data = await res.json()
|
||||||
|
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, `/leads/${data.id}`)
|
||||||
|
router.push("/leads")
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Create lead error:", e)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href="/leads"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to leads
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Create New Lead</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="companyName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Company Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Acme Corp" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="contactName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Contact Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="John Doe" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="john@acme.com" type="email" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Phone</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="(555) 123-4567" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="source"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Source</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select source" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{LEAD_SOURCES.map((source) => (
|
||||||
|
<SelectItem key={source} value={source}>
|
||||||
|
{source}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Status</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(LEAD_STATUSES).map(([key, { label }]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Brief description of the lead and their requirements..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="assignedUserId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Assign To</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Unassigned</SelectItem>
|
||||||
|
{users.filter((u) => u.active).map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.name} ({user.role})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Create Lead"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/leads")}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { LeadsTable } from "@/components/leads/leads-table"
|
||||||
|
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||||
|
import { Lead } from "@/types"
|
||||||
|
|
||||||
|
export default function LeadsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all")
|
||||||
|
const [periodFilter, setPeriodFilter] = useState("all")
|
||||||
|
const [leadsData, setLeadsData] = useState<Lead[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (search) params.set("search", search)
|
||||||
|
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||||
|
if (periodFilter !== "all") params.set("period", periodFilter)
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
fetch(`/api/leads?${params.toString()}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setLeadsData(data)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false))
|
||||||
|
}, [search, statusFilter, periodFilter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Leads"
|
||||||
|
description="Manage and track your sales leads"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<div className="p-4">
|
||||||
|
<LeadsTableToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
|
periodFilter={periodFilter}
|
||||||
|
onPeriodFilterChange={setPeriodFilter}
|
||||||
|
onCreateClick={() => router.push("/leads/new")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LeadsTable data={leadsData} loading={loading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+24
-4
@@ -11,9 +11,10 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, updateAvatar } = useUser()
|
const { user, updateAvatar } = useUser()
|
||||||
|
if (!user) return null
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const validTypes = ["image/png", "image/jpeg"]
|
const validTypes = ["image/png", "image/jpeg"]
|
||||||
@@ -21,9 +22,28 @@ export default function ProfilePage() {
|
|||||||
toast.error("Only PNG and JPEG files are allowed")
|
toast.error("Only PNG and JPEG files are allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const url = URL.createObjectURL(file)
|
const reader = new FileReader()
|
||||||
updateAvatar(url)
|
reader.onload = async () => {
|
||||||
toast.success("Avatar updated")
|
const dataUrl = reader.result as string
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/users/avatar", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ avatar: dataUrl }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
updateAvatar(data.avatar)
|
||||||
|
toast.success("Avatar updated")
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to save avatar")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn("Failed to save avatar")
|
||||||
|
toast.error("Failed to save avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { UsersTable } from "@/components/users/users-table"
|
||||||
|
import { UserFormDialog } from "@/components/users/user-form-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { useUser } from "@/providers/user-provider"
|
||||||
|
import { Plus, Users as UsersIcon, Search } from "lucide-react"
|
||||||
|
import type { User } from "@/types"
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const { user } = useUser()
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/users")
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setUsers(data.users || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn("Failed to fetch users in users page")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
}, [fetchUsers])
|
||||||
|
|
||||||
|
const activeUsers = users.filter((u) => u.active !== false)
|
||||||
|
const admins = users.filter((u) => u.role === "admin")
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: "Total Users", value: users.length, color: "bg-blue-500/10", textColor: "text-blue-500" },
|
||||||
|
{ label: "Active", value: activeUsers.length, color: "bg-green-500/10", textColor: "text-green-500" },
|
||||||
|
{ label: "Admins", value: admins.length, color: "bg-purple-500/10", textColor: "text-purple-500" },
|
||||||
|
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filtered = users.filter((u) => {
|
||||||
|
if (!search) return true
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Users"
|
||||||
|
description="Manage team members and their roles"
|
||||||
|
>
|
||||||
|
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<div key={s.label} className="rounded-lg border bg-card p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.color}`}
|
||||||
|
>
|
||||||
|
<UsersIcon className={`h-5 w-5 ${s.textColor}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{s.value}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="h-9 pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserFormDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onUserCreated={fetchUsers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { chatWithAI } from "@/lib/ai"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
if (!["sales", "admin", "super_admin"].includes(user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = await request.json()
|
||||||
|
if (!message || typeof message !== "string") {
|
||||||
|
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward the JWT from the session cookie to the Rust backend
|
||||||
|
const sessionCookie = request.cookies.get("session")?.value
|
||||||
|
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 })
|
||||||
|
|
||||||
|
const response = await chatWithAI(message, sessionCookie)
|
||||||
|
|
||||||
|
return NextResponse.json({ response })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("AI chat error:", error)
|
||||||
|
return NextResponse.json({ error: "AI service unavailable" }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { fetchJobs } from "@/lib/ai"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
if (!["sales", "admin", "super_admin"].includes(user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = await fetchJobs()
|
||||||
|
return NextResponse.json({ jobs })
|
||||||
|
} catch {
|
||||||
|
console.warn("Failed to fetch AI jobs in API route")
|
||||||
|
return NextResponse.json({ jobs: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import {
|
||||||
|
comparePassword,
|
||||||
|
getUserByEmail,
|
||||||
|
getUserByUsername,
|
||||||
|
mapDbUserToSessionUser,
|
||||||
|
recordLoginAttempt,
|
||||||
|
incrementFailedAttempts,
|
||||||
|
resetFailedAttempts,
|
||||||
|
isAccountLocked,
|
||||||
|
createSession,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email, username, password } = await request.json()
|
||||||
|
|
||||||
|
const credential = email || username
|
||||||
|
|
||||||
|
if (!credential || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Email/Username and password are required." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credential.trim().length === 0 || password.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credentials cannot be empty." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipAddress =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"127.0.0.1"
|
||||||
|
|
||||||
|
const userAgent = request.headers.get("user-agent") || null
|
||||||
|
|
||||||
|
// Try to find user by email first, then by username
|
||||||
|
let dbUser =
|
||||||
|
email || credential.includes("@")
|
||||||
|
? await getUserByEmail(credential)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
dbUser = await getUserByUsername(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
await recordLoginAttempt(
|
||||||
|
null,
|
||||||
|
credential,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
false,
|
||||||
|
"User not found"
|
||||||
|
)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid email/username or password." },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockStatus = await isAccountLocked(dbUser)
|
||||||
|
if (lockStatus.locked) {
|
||||||
|
await recordLoginAttempt(
|
||||||
|
dbUser.id,
|
||||||
|
credential,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
false,
|
||||||
|
lockStatus.reason
|
||||||
|
)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: lockStatus.reason },
|
||||||
|
{ status: 423 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await comparePassword(password, dbUser.password_hash)
|
||||||
|
if (!valid) {
|
||||||
|
await incrementFailedAttempts(dbUser.id)
|
||||||
|
await recordLoginAttempt(
|
||||||
|
dbUser.id,
|
||||||
|
credential,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
false,
|
||||||
|
"Invalid password"
|
||||||
|
)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid email/username or password." },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetFailedAttempts(dbUser.id)
|
||||||
|
await recordLoginAttempt(
|
||||||
|
dbUser.id,
|
||||||
|
credential,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
await createSession(dbUser.id, dbUser.role_name)
|
||||||
|
|
||||||
|
const user = mapDbUserToSessionUser(dbUser)
|
||||||
|
|
||||||
|
return NextResponse.json({ user }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication service unavailable." },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { destroySession } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
await destroySession()
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Logout failed." },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ user }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth me error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication service unavailable." },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const { searchParams } = new URL(_request.url)
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "100", 10)
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
const before = searchParams.get("before") || ""
|
||||||
|
|
||||||
|
// Verify user is a participant
|
||||||
|
const partCheck = await query(
|
||||||
|
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
||||||
|
[id, user.id]
|
||||||
|
)
|
||||||
|
if (partCheck.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgSql = `SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
|
||||||
|
u.first_name || ' ' || u.last_name AS sender_name,
|
||||||
|
u.email AS sender_email,
|
||||||
|
u.avatar_url AS sender_avatar_url
|
||||||
|
FROM messages m
|
||||||
|
JOIN users u ON u.id = m.sender_id
|
||||||
|
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
|
||||||
|
const msgParams: any[] = [id]
|
||||||
|
|
||||||
|
if (before) {
|
||||||
|
msgSql += ` AND m.created_at < $2`
|
||||||
|
msgParams.push(before)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgSql += ` ORDER BY m.created_at ASC`
|
||||||
|
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
|
||||||
|
msgParams.push(limit, offset)
|
||||||
|
|
||||||
|
const [msgResult, otherReadResult] = await Promise.all([
|
||||||
|
query(msgSql, msgParams),
|
||||||
|
query(
|
||||||
|
`SELECT last_read_at FROM conversation_participants
|
||||||
|
WHERE conversation_id = $1 AND user_id != $2`,
|
||||||
|
[id, user.id],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const otherLastReadAt = otherReadResult.rows[0]?.last_read_at
|
||||||
|
? new Date(otherReadResult.rows[0].last_read_at).getTime()
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const messages = msgResult.rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
conversationId: id,
|
||||||
|
senderId: row.sender_id,
|
||||||
|
senderName: row.sender_name,
|
||||||
|
senderAvatar: avatarSvgUrl(row.sender_name),
|
||||||
|
content: row.content,
|
||||||
|
timestamp: formatTime(new Date(row.created_at)),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
read: row.sender_id === user.id
|
||||||
|
? new Date(row.created_at).getTime() <= otherLastReadAt
|
||||||
|
: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ messages })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Messages error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load messages" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
// Verify user is a participant
|
||||||
|
const partCheck = await query(
|
||||||
|
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
||||||
|
[id, user.id]
|
||||||
|
)
|
||||||
|
if (partCheck.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content } = await request.json()
|
||||||
|
if (!content?.trim()) {
|
||||||
|
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO messages (conversation_id, sender_id, content)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
[id, user.id, content.trim()],
|
||||||
|
)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
senderAvatar: user.avatar,
|
||||||
|
content: content.trim(),
|
||||||
|
timestamp: formatTime(new Date(msg.created_at)),
|
||||||
|
createdAt: msg.created_at,
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Send message error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to send message" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
const now = new Date()
|
||||||
|
const isToday = date.toDateString() === now.toDateString()
|
||||||
|
if (isToday) {
|
||||||
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
||||||
|
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE conversation_participants
|
||||||
|
SET last_read_at = NOW()
|
||||||
|
WHERE conversation_id = $1 AND user_id = $2`,
|
||||||
|
[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)
|
||||||
|
return NextResponse.json({ error: "Failed to mark as read" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT
|
||||||
|
c.id,
|
||||||
|
c.updated_at,
|
||||||
|
cp_me.last_read_at,
|
||||||
|
u.id AS other_user_id,
|
||||||
|
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||||
|
u.email AS other_user_email,
|
||||||
|
u.avatar_url AS other_user_avatar_url,
|
||||||
|
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||||
|
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||||
|
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
||||||
|
FROM conversations c
|
||||||
|
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
||||||
|
JOIN conversation_participants cp ON cp.conversation_id = c.id
|
||||||
|
JOIN users u ON u.id = cp.user_id AND u.id != $1
|
||||||
|
WHERE c.id IN (
|
||||||
|
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||||
|
)
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
const conversations = result.rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
otherUser: {
|
||||||
|
id: row.other_user_id,
|
||||||
|
name: row.other_user_name,
|
||||||
|
email: row.other_user_email,
|
||||||
|
avatar: avatarSvgUrl(row.other_user_name),
|
||||||
|
},
|
||||||
|
lastMessage: row.last_message || "",
|
||||||
|
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "",
|
||||||
|
unread: parseInt(row.unread) || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ conversations })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Conversations error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load conversations" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { userId } = await request.json()
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "userId is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId === user.id) {
|
||||||
|
return NextResponse.json({ error: "Cannot start a conversation with yourself" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await query(
|
||||||
|
`SELECT c.id FROM conversations c
|
||||||
|
JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = $1
|
||||||
|
JOIN conversation_participants cp2 ON cp2.conversation_id = c.id AND cp2.user_id = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[user.id, userId],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
return NextResponse.json({ conversationId: existing.rows[0].id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const convResult = await query(
|
||||||
|
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
||||||
|
)
|
||||||
|
const conversationId = convResult.rows[0].id
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||||
|
[conversationId, user.id, userId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const otherUser = await query(
|
||||||
|
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||||
|
FROM users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const other = otherUser.rows[0]
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
conversation: {
|
||||||
|
id: conversationId,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
otherUser: {
|
||||||
|
id: other.id,
|
||||||
|
name: other.name,
|
||||||
|
email: other.email,
|
||||||
|
avatar: avatarSvgUrl(other.name),
|
||||||
|
},
|
||||||
|
lastMessage: "",
|
||||||
|
lastMessageTime: "",
|
||||||
|
unread: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Create conversation error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to create conversation" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(date: Date): string {
|
||||||
|
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||||
|
if (seconds < 60) return "now"
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days}d ago`
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
||||||
|
const end = new Date()
|
||||||
|
let start: Date
|
||||||
|
switch (period) {
|
||||||
|
case "7days":
|
||||||
|
start = new Date(end); start.setDate(start.getDate() - 7); break
|
||||||
|
case "30days":
|
||||||
|
start = new Date(end); start.setDate(start.getDate() - 30); break
|
||||||
|
case "12months":
|
||||||
|
start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
|
||||||
|
case "6months":
|
||||||
|
default:
|
||||||
|
start = new Date(end); start.setMonth(start.getMonth() - 6); break
|
||||||
|
}
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
|
||||||
|
const end = new Date(currentStart)
|
||||||
|
const diff = end.getTime() - currentStart.getTime()
|
||||||
|
const start = new Date(end.getTime() - diff)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodLabels: Record<string, string> = {
|
||||||
|
"7days": "Last 7 days",
|
||||||
|
"30days": "Last 30 days",
|
||||||
|
"6months": "Last 6 months",
|
||||||
|
"12months": "Last 12 months",
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageToStatus(name: string): string {
|
||||||
|
switch (name) {
|
||||||
|
case "New": return "open"
|
||||||
|
case "Contacted": return "contacted"
|
||||||
|
case "Qualified":
|
||||||
|
case "Interested":
|
||||||
|
case "Demo Scheduled":
|
||||||
|
case "Negotiation": return "pending"
|
||||||
|
case "Closed Won": return "closed"
|
||||||
|
case "Closed Lost": return "ignored"
|
||||||
|
default: return "open"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLeadsInRange(start: Date, end: Date) {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||||
|
l.notes, l.assigned_to, l.score,
|
||||||
|
ls.name AS stage_name,
|
||||||
|
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||||
|
FROM leads l
|
||||||
|
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||||
|
LEFT JOIN users u ON u.id = l.assigned_to
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
|
AND l.created_at >= $1 AND l.created_at <= $2
|
||||||
|
ORDER BY l.created_at DESC`,
|
||||||
|
[start.toISOString(), end.toISOString()]
|
||||||
|
)
|
||||||
|
return result.rows.map((r: any) => ({
|
||||||
|
...r,
|
||||||
|
status: stageToStatus(r.stage_name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function countStatuses(leads: any[]) {
|
||||||
|
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||||
|
leads.forEach((l: any) => {
|
||||||
|
const s = l.status as keyof typeof counts
|
||||||
|
if (s in counts) counts[s]++
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthlyBreakdown(leads: any[], period: string) {
|
||||||
|
const { start, end } = getPeriodDateRange(period)
|
||||||
|
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
|
||||||
|
const current = new Date(start)
|
||||||
|
const isMonthly = period === "6months" || period === "12months"
|
||||||
|
|
||||||
|
while (current <= end) {
|
||||||
|
const label = isMonthly
|
||||||
|
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||||
|
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||||
|
|
||||||
|
const ps = new Date(current)
|
||||||
|
const pe = isMonthly
|
||||||
|
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
|
||||||
|
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
|
||||||
|
|
||||||
|
const inPeriod = leads.filter((l: any) => {
|
||||||
|
const d = new Date(l.created_at)
|
||||||
|
return d >= ps && d <= pe
|
||||||
|
})
|
||||||
|
|
||||||
|
const counts = countStatuses(inPeriod)
|
||||||
|
result.push({ label, total: inPeriod.length, ...counts })
|
||||||
|
|
||||||
|
if (isMonthly) current.setMonth(current.getMonth() + 1)
|
||||||
|
else current.setDate(current.getDate() + 1)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
|
||||||
|
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
||||||
|
const pct = Math.round(((current - previous) / previous) * 100)
|
||||||
|
return { pct: Math.abs(pct), up: pct >= 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const period = searchParams.get("period") || "6months"
|
||||||
|
const yearParam = searchParams.get("year")
|
||||||
|
let start: Date, end: Date, prevRange: { start: Date; end: Date }
|
||||||
|
if (yearParam) {
|
||||||
|
const y = parseInt(yearParam)
|
||||||
|
start = new Date(y, 0, 1)
|
||||||
|
end = new Date(y, 11, 31, 23, 59, 59)
|
||||||
|
prevRange = { start: new Date(y - 1, 0, 1), end: new Date(y - 1, 11, 31, 23, 59, 59) }
|
||||||
|
} else {
|
||||||
|
const r = getPeriodDateRange(period)
|
||||||
|
start = r.start; end = r.end
|
||||||
|
prevRange = getPreviousPeriodRange(period, start)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [currentLeads, prevLeads] = await Promise.all([
|
||||||
|
fetchLeadsInRange(start, end),
|
||||||
|
fetchLeadsInRange(prevRange.start, prevRange.end),
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentCounts = countStatuses(currentLeads)
|
||||||
|
const prevCounts = countStatuses(prevLeads)
|
||||||
|
|
||||||
|
const totalLeads = currentLeads.length
|
||||||
|
const closedLeads = currentCounts.closed
|
||||||
|
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
||||||
|
|
||||||
|
const mappedLeads = currentLeads.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
companyName: r.company_name || "",
|
||||||
|
contactName: r.contact_name,
|
||||||
|
email: r.email || "",
|
||||||
|
phone: r.phone || "",
|
||||||
|
source: "",
|
||||||
|
description: r.notes || "",
|
||||||
|
status: r.status,
|
||||||
|
assignedUserId: r.assigned_to,
|
||||||
|
assignedUser: r.assigned_to ? {
|
||||||
|
id: r.user_id,
|
||||||
|
name: `${r.first_name} ${r.last_name}`,
|
||||||
|
email: r.user_email,
|
||||||
|
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||||
|
} : null,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
|
||||||
|
|
||||||
|
const trends = {
|
||||||
|
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
||||||
|
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
||||||
|
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
||||||
|
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
||||||
|
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
||||||
|
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
||||||
|
conversionRate: computeTrend(conversionRate,
|
||||||
|
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalLeads,
|
||||||
|
openLeads: currentCounts.open,
|
||||||
|
contactedLeads: currentCounts.contacted,
|
||||||
|
pendingLeads: currentCounts.pending,
|
||||||
|
closedLeads,
|
||||||
|
ignoredLeads: currentCounts.ignored,
|
||||||
|
conversionRate,
|
||||||
|
monthlyBreakdown,
|
||||||
|
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||||
|
trends,
|
||||||
|
recentLeads: mappedLeads.slice(0, 10),
|
||||||
|
statusDistribution: [
|
||||||
|
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||||
|
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||||
|
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
|
||||||
|
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
|
||||||
|
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
|
||||||
|
],
|
||||||
|
periodLabel: periodLabels[period] ?? "Selected period",
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(stats)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Dashboard API error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const { content } = await request.json()
|
||||||
|
if (!content?.trim()) {
|
||||||
|
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO customer_notes (customer_id, author_id, content)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[id, user.id, content.trim()]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Create note error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to create note" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
|
||||||
|
u.id AS user_id, u.first_name, u.last_name, u.avatar_url
|
||||||
|
FROM customer_notes cn
|
||||||
|
JOIN users u ON u.id = cn.author_id
|
||||||
|
WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL
|
||||||
|
ORDER BY cn.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`,
|
||||||
|
[id, limit, offset]
|
||||||
|
)
|
||||||
|
|
||||||
|
const notes = result.rows.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
leadId: id,
|
||||||
|
userId: r.user_id,
|
||||||
|
authorName: `${r.first_name} ${r.last_name}`,
|
||||||
|
authorAvatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||||
|
note: r.content,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json(notes)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lead notes API error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load notes" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
function stageToStatus(name: string): string {
|
||||||
|
switch (name) {
|
||||||
|
case "New": return "open"
|
||||||
|
case "Contacted": return "contacted"
|
||||||
|
case "Qualified":
|
||||||
|
case "Interested":
|
||||||
|
case "Demo Scheduled":
|
||||||
|
case "Negotiation": return "pending"
|
||||||
|
case "Closed Won": return "closed"
|
||||||
|
case "Closed Lost": return "ignored"
|
||||||
|
default: return "open"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||||
|
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||||
|
ls.name AS stage_name,
|
||||||
|
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||||
|
FROM leads l
|
||||||
|
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||||
|
LEFT JOIN users u ON u.id = l.assigned_to
|
||||||
|
WHERE l.id = $1 AND l.deleted_at IS NULL
|
||||||
|
AND ($2 = true OR l.assigned_to = $3)`,
|
||||||
|
[id, isAdmin, user.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = result.rows[0]
|
||||||
|
const lead = {
|
||||||
|
id: r.id,
|
||||||
|
companyName: r.company_name || "",
|
||||||
|
contactName: r.contact_name,
|
||||||
|
email: r.email || "",
|
||||||
|
phone: r.phone || "",
|
||||||
|
source: "",
|
||||||
|
description: r.notes || "",
|
||||||
|
status: stageToStatus(r.stage_name),
|
||||||
|
assignedUserId: r.assigned_to,
|
||||||
|
assignedUser: r.assigned_to ? {
|
||||||
|
id: r.user_id,
|
||||||
|
name: `${r.first_name} ${r.last_name}`,
|
||||||
|
email: r.user_email,
|
||||||
|
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||||
|
} : null,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(lead)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lead detail API error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToStageName(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "open": return "New"
|
||||||
|
case "contacted": return "Contacted"
|
||||||
|
case "pending": return "Qualified"
|
||||||
|
case "closed": return "Closed Won"
|
||||||
|
case "ignored": return "Closed Lost"
|
||||||
|
default: return "New"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
const accessCheck = await query(
|
||||||
|
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
AND ($2 = true OR assigned_to = $3)`,
|
||||||
|
[id, isAdmin, user.id]
|
||||||
|
)
|
||||||
|
if (accessCheck.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const fields: string[] = []
|
||||||
|
const values: any[] = []
|
||||||
|
let idx = 1
|
||||||
|
|
||||||
|
if (body.companyName !== undefined) { fields.push(`company_name = $${idx++}`); values.push(body.companyName) }
|
||||||
|
if (body.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) }
|
||||||
|
if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) }
|
||||||
|
if (body.phone !== undefined) { fields.push(`phone = $${idx++}`); values.push(body.phone) }
|
||||||
|
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
|
||||||
|
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
|
||||||
|
if (body.assignedUserId !== undefined) {
|
||||||
|
fields.push(`assigned_to = $${idx++}`)
|
||||||
|
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
||||||
|
}
|
||||||
|
if (body.status !== undefined) {
|
||||||
|
const stageName = statusToStageName(body.status)
|
||||||
|
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
|
||||||
|
if (stageResult.rows.length > 0) {
|
||||||
|
fields.push(`stage_id = $${idx++}`)
|
||||||
|
values.push(stageResult.rows[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.score !== undefined) {
|
||||||
|
const score = Number(body.score)
|
||||||
|
if (!isFinite(score) || score < 0 || score > 100) {
|
||||||
|
return NextResponse.json({ error: "Score must be a number between 0 and 100" }, { status: 400 })
|
||||||
|
}
|
||||||
|
fields.push(`score = $${idx++}`)
|
||||||
|
values.push(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(`updated_at = NOW()`)
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
const sql = `UPDATE leads SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING id`
|
||||||
|
const result = await query(sql, values)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, id: result.rows[0].id })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lead PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to update lead" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
function stageToStatus(name: string): string {
|
||||||
|
switch (name) {
|
||||||
|
case "New": return "open"
|
||||||
|
case "Contacted": return "contacted"
|
||||||
|
case "Qualified":
|
||||||
|
case "Interested":
|
||||||
|
case "Demo Scheduled":
|
||||||
|
case "Negotiation": return "pending"
|
||||||
|
case "Closed Won": return "closed"
|
||||||
|
case "Closed Lost": return "ignored"
|
||||||
|
default: return "open"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
|
||||||
|
if (period === "all") return null
|
||||||
|
const end = new Date()
|
||||||
|
let start: Date
|
||||||
|
switch (period) {
|
||||||
|
case "7days": start = new Date(end); start.setDate(start.getDate() - 7); break
|
||||||
|
case "30days": start = new Date(end); start.setDate(start.getDate() - 30); break
|
||||||
|
case "12months": start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
|
||||||
|
case "6months": default: start = new Date(end); start.setMonth(start.getMonth() - 6); break
|
||||||
|
}
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const search = searchParams.get("search") || ""
|
||||||
|
const status = searchParams.get("status") || "all"
|
||||||
|
const period = searchParams.get("period") || "all"
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
|
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||||
|
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||||
|
ls.name AS stage_name,
|
||||||
|
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||||
|
FROM leads l
|
||||||
|
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||||
|
LEFT JOIN users u ON u.id = l.assigned_to
|
||||||
|
WHERE l.deleted_at IS NULL`
|
||||||
|
|
||||||
|
const params: any[] = []
|
||||||
|
let paramIdx = 1
|
||||||
|
|
||||||
|
if (period !== "all") {
|
||||||
|
const range = getPeriodDateRange(period)
|
||||||
|
if (range) {
|
||||||
|
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
|
||||||
|
params.push(range.start.toISOString(), range.end.toISOString())
|
||||||
|
paramIdx += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
|
||||||
|
params.push(`%${search}%`)
|
||||||
|
paramIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
sql += ` AND l.assigned_to = $${paramIdx}`
|
||||||
|
params.push(user.id)
|
||||||
|
paramIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY l.created_at DESC`
|
||||||
|
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
||||||
|
params.push(limit, offset)
|
||||||
|
paramIdx += 2
|
||||||
|
|
||||||
|
const result = await query(sql, params)
|
||||||
|
|
||||||
|
let leads = result.rows.map((r: any) => {
|
||||||
|
const s = stageToStatus(r.stage_name)
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
companyName: r.company_name || "",
|
||||||
|
contactName: r.contact_name,
|
||||||
|
email: r.email || "",
|
||||||
|
phone: r.phone || "",
|
||||||
|
source: "",
|
||||||
|
description: r.notes || "",
|
||||||
|
status: s,
|
||||||
|
assignedUserId: r.assigned_to,
|
||||||
|
assignedUser: r.assigned_to ? {
|
||||||
|
id: r.user_id,
|
||||||
|
name: `${r.first_name} ${r.last_name}`,
|
||||||
|
email: r.user_email,
|
||||||
|
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||||
|
} : null,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (status !== "all") {
|
||||||
|
leads = leads.filter((l: any) => l.status === status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(leads)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Leads API error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const stageResult = await query(
|
||||||
|
"SELECT id FROM lead_stages WHERE name = $1",
|
||||||
|
[body.status === "open" ? "New" : "Contacted"]
|
||||||
|
)
|
||||||
|
const stageId = stageResult.rows[0]?.id || 1
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
body.companyName,
|
||||||
|
body.contactName,
|
||||||
|
body.email,
|
||||||
|
body.phone || null,
|
||||||
|
body.description || null,
|
||||||
|
body.source || null,
|
||||||
|
stageId,
|
||||||
|
body.assignedUserId === "none" ? null : body.assignedUserId,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Leads POST error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to create lead" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const id = searchParams.get("id")
|
||||||
|
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
"UPDATE leads SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3) RETURNING id",
|
||||||
|
[id, isAdmin, user.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Leads DELETE error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to delete lead" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 RETURNING id`,
|
||||||
|
[id, user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Notification PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to mark notification as read" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`DELETE FROM notifications WHERE id = $1 AND user_id = $2 RETURNING id`,
|
||||||
|
[id, user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Notification DELETE error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to dismiss notification" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT lead_assigned, lead_status, note_added, daily_digest, weekly_report
|
||||||
|
FROM notification_preferences
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
leadAssigned: true,
|
||||||
|
leadStatus: true,
|
||||||
|
noteAdded: false,
|
||||||
|
dailyDigest: false,
|
||||||
|
weeklyReport: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = result.rows[0]
|
||||||
|
return NextResponse.json({
|
||||||
|
leadAssigned: r.lead_assigned,
|
||||||
|
leadStatus: r.lead_status,
|
||||||
|
noteAdded: r.note_added,
|
||||||
|
dailyDigest: r.daily_digest,
|
||||||
|
weeklyReport: r.weekly_report,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Preferences GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE SET lead_assigned = $2, lead_status = $3, note_added = $4,
|
||||||
|
daily_digest = $5, weekly_report = $6, updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
user.id,
|
||||||
|
body.leadAssigned ?? true,
|
||||||
|
body.leadStatus ?? true,
|
||||||
|
body.noteAdded ?? false,
|
||||||
|
body.dailyDigest ?? false,
|
||||||
|
body.weeklyReport ?? true,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Preferences PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`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
|
||||||
|
LIMIT 50`,
|
||||||
|
[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
const notifications = result.rows.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
link: r.link,
|
||||||
|
read: r.is_read,
|
||||||
|
timestamp: r.created_at,
|
||||||
|
contextId: r.context_id,
|
||||||
|
contextType: r.context_type,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const unreadResult = await query(
|
||||||
|
`SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`,
|
||||||
|
[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
notifications,
|
||||||
|
unreadCount: parseInt(unreadResult.rows[0].count, 10),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Notifications GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load notifications" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { type, title, description, link, userId } = await request.json()
|
||||||
|
const targetUserId = userId || user.id
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, description, link)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, type, title, description, link, is_read, created_at`,
|
||||||
|
[targetUserId, type, title, description || null, link || null],
|
||||||
|
)
|
||||||
|
|
||||||
|
const notif = result.rows[0]
|
||||||
|
return NextResponse.json({
|
||||||
|
id: notif.id,
|
||||||
|
type: notif.type,
|
||||||
|
title: notif.title,
|
||||||
|
description: notif.description,
|
||||||
|
link: notif.link,
|
||||||
|
read: notif.is_read,
|
||||||
|
timestamp: notif.created_at,
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Notifications POST error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to create notification" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`,
|
||||||
|
[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Notifications PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to mark all as read" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(`SELECT * FROM company_settings ORDER BY updated_at DESC LIMIT 1`)
|
||||||
|
const row = result.rows[0]
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({
|
||||||
|
companyName: "Coastal IT Solutions",
|
||||||
|
companyEmail: "info@coastalit.com",
|
||||||
|
companyPhone: "(555) 123-4567",
|
||||||
|
companyWebsite: "https://coastalit.com",
|
||||||
|
companyAddress: "123 Business Ave, Suite 100, San Francisco, CA 94105",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
companyName: row.company_name || "",
|
||||||
|
companyEmail: row.company_email || "",
|
||||||
|
companyPhone: row.company_phone || "",
|
||||||
|
companyWebsite: row.company_website || "",
|
||||||
|
companyAddress: row.company_address || "",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Company settings GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load company settings" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE company_settings SET
|
||||||
|
company_name = $1, company_email = $2, company_phone = $3,
|
||||||
|
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
body.companyName || "",
|
||||||
|
body.companyEmail || "",
|
||||||
|
body.companyPhone || "",
|
||||||
|
body.companyWebsite || "",
|
||||||
|
body.companyAddress || "",
|
||||||
|
user.id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Company settings PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to save company settings" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT timezone, date_format, items_per_page FROM user_preferences WHERE user_id = $1`,
|
||||||
|
[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
timezone: "america-los_angeles",
|
||||||
|
dateFormat: "mdy",
|
||||||
|
itemsPerPage: 20,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = result.rows[0]
|
||||||
|
return NextResponse.json({
|
||||||
|
timezone: r.timezone,
|
||||||
|
dateFormat: r.date_format,
|
||||||
|
itemsPerPage: r.items_per_page,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Preferences GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE SET timezone = $2, date_format = $3, items_per_page = $4, updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
user.id,
|
||||||
|
body.timezone || "america-los_angeles",
|
||||||
|
body.dateFormat || "mdy",
|
||||||
|
body.itemsPerPage || 20,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Preferences PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import os from "os"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
let prevCpu = process.cpuUsage()
|
||||||
|
let prevTime = Date.now()
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const elapsed = now - prevTime
|
||||||
|
const currentCpu = process.cpuUsage()
|
||||||
|
|
||||||
|
const user = currentCpu.user - prevCpu.user
|
||||||
|
const sys = currentCpu.system - prevCpu.system
|
||||||
|
const totalUs = user + sys
|
||||||
|
|
||||||
|
// CPU time (ms) / wall time (ms) * 100 = % of one core
|
||||||
|
const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0
|
||||||
|
|
||||||
|
prevCpu = currentCpu
|
||||||
|
prevTime = now
|
||||||
|
|
||||||
|
const mem = process.memoryUsage()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rssMB: Math.round(mem.rss / 1024 / 1024),
|
||||||
|
cpuPct,
|
||||||
|
cores: os.cpus().length,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Only super admins can delete users" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
if (id === sessionUser.id) {
|
||||||
|
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting user:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { avatar } = await request.json()
|
||||||
|
if (!avatar || typeof avatar !== "string") {
|
||||||
|
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||||
|
[avatar, user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ avatar })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Avatar upload error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to update avatar" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { hashPassword, getSessionUser } from "@/lib/auth"
|
||||||
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||||
|
u.is_active AS active, u.created_at, u.avatar_url,
|
||||||
|
r.name AS role
|
||||||
|
FROM users u
|
||||||
|
JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2`,
|
||||||
|
[limit, offset]
|
||||||
|
)
|
||||||
|
const users = result.rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: `${row.first_name} ${row.last_name}`,
|
||||||
|
email: row.email,
|
||||||
|
role: row.role.toLowerCase(),
|
||||||
|
active: row.active,
|
||||||
|
avatar: avatarSvgUrl(`${row.first_name} ${row.last_name}`),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}))
|
||||||
|
return NextResponse.json({ users }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Only super admins can create users" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, password, role, active } = await request.json()
|
||||||
|
if (!name || !email || !password || !role) {
|
||||||
|
return NextResponse.json({ error: "Name, email, password, and role are required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRoles = ["sales", "admin", "super_admin", "dev"]
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameParts = name.trim().split(/\s+/)
|
||||||
|
const firstName = nameParts[0]
|
||||||
|
const lastName = nameParts.slice(1).join(" ") || firstName
|
||||||
|
const username = email.split("@")[0]
|
||||||
|
const passwordHash = await hashPassword(password)
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id`,
|
||||||
|
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const roleId = (
|
||||||
|
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
|
||||||
|
).rows[0]?.id
|
||||||
|
|
||||||
|
if (roleId) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO user_roles (user_id, role_id, assigned_by)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[result.rows[0].id, roleId, sessionUser.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error creating user:", error)
|
||||||
|
if (error?.constraint === "uq_users_username" || error?.constraint === "uq_users_email") {
|
||||||
|
return NextResponse.json({ error: "A user with this email or username already exists" }, { status: 409 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: error?.message || "Failed to create user" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const currentUser = await getSessionUser()
|
||||||
|
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const q = request.nextUrl.searchParams.get("q") || ""
|
||||||
|
|
||||||
|
if (!q.trim()) {
|
||||||
|
return NextResponse.json({ users: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||||
|
FROM users
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND id != $1
|
||||||
|
AND (LOWER(first_name || ' ' || last_name) LIKE LOWER($2)
|
||||||
|
OR LOWER(email) LIKE LOWER($2))
|
||||||
|
ORDER BY first_name ASC
|
||||||
|
LIMIT 10`,
|
||||||
|
[currentUser.id, `%${q}%`],
|
||||||
|
)
|
||||||
|
|
||||||
|
const users = result.rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
avatar: avatarSvgUrl(row.name),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ users })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("User search error:", error)
|
||||||
|
return NextResponse.json({ error: "Search failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: 210 40% 98%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 221.2 83.2% 53.3%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 210 40% 96.1%;
|
||||||
|
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-border: 214.3 31.8% 91.4%;
|
||||||
|
--sidebar-ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 48%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 217.2 91.2% 59.8%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 224.3 76.3% 48%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocean {
|
||||||
|
--primary: 187 75% 42%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 187 75% 42%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 187 75% 42%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 187 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 187 50% 20%;
|
||||||
|
--sidebar-border: 187 20% 88%;
|
||||||
|
--sidebar-ring: 187 75% 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.ocean {
|
||||||
|
--primary: 187 75% 50%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 187 75% 50%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 187 75% 55%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 187 75% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forest {
|
||||||
|
--primary: 142 76% 36%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 142 76% 36%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 142 76% 36%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 142 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 142 50% 20%;
|
||||||
|
--sidebar-border: 142 20% 88%;
|
||||||
|
--sidebar-ring: 142 76% 36%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.forest {
|
||||||
|
--primary: 142 76% 44%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 142 76% 44%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 142 76% 50%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 142 76% 44%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sunset {
|
||||||
|
--primary: 24 95% 53%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 24 95% 53%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 24 95% 53%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 24 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 24 50% 20%;
|
||||||
|
--sidebar-border: 24 20% 88%;
|
||||||
|
--sidebar-ring: 24 95% 53%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.sunset {
|
||||||
|
--primary: 24 95% 58%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 24 95% 58%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 24 95% 65%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 24 95% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.midnight {
|
||||||
|
--primary: 230 75% 55%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 230 75% 55%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 230 75% 55%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 230 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 230 50% 20%;
|
||||||
|
--sidebar-border: 230 20% 88%;
|
||||||
|
--sidebar-ring: 230 75% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.midnight {
|
||||||
|
--primary: 230 75% 62%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 230 75% 62%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 230 75% 65%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 230 75% 62%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rose {
|
||||||
|
--primary: 346 77% 50%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 346 77% 50%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 346 77% 50%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 346 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 346 50% 20%;
|
||||||
|
--sidebar-border: 346 20% 88%;
|
||||||
|
--sidebar-ring: 346 77% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.rose {
|
||||||
|
--primary: 346 77% 58%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 346 77% 58%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 346 77% 60%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 346 77% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amber {
|
||||||
|
--primary: 38 92% 50%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 38 92% 50%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 38 92% 50%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 38 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 38 50% 20%;
|
||||||
|
--sidebar-border: 38 20% 88%;
|
||||||
|
--sidebar-ring: 38 92% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.amber {
|
||||||
|
--primary: 38 92% 56%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 38 92% 56%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 38 92% 60%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 38 92% 56%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.violet {
|
||||||
|
--primary: 262 83% 58%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 262 83% 58%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 262 83% 58%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 262 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 262 50% 20%;
|
||||||
|
--sidebar-border: 262 20% 88%;
|
||||||
|
--sidebar-ring: 262 83% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.violet {
|
||||||
|
--primary: 262 83% 65%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 262 83% 65%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 262 83% 68%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 262 83% 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slate {
|
||||||
|
--primary: 215 20% 45%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 215 20% 45%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 215 20% 45%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 215 20% 94%;
|
||||||
|
--sidebar-accent-foreground: 215 50% 20%;
|
||||||
|
--sidebar-border: 215 20% 88%;
|
||||||
|
--sidebar-ring: 215 20% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.slate {
|
||||||
|
--primary: 215 20% 60%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 215 20% 60%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 215 20% 65%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 215 20% 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ruby {
|
||||||
|
--primary: 351 85% 45%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 351 85% 45%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 351 85% 45%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 351 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 351 50% 20%;
|
||||||
|
--sidebar-border: 351 20% 88%;
|
||||||
|
--sidebar-ring: 351 85% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.ruby {
|
||||||
|
--primary: 351 85% 52%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 351 85% 52%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 351 85% 55%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 351 85% 52%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login page custom styles */
|
||||||
|
.left-panel {
|
||||||
|
background: #0a0a0f;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.right-panel {
|
||||||
|
background: #111118;
|
||||||
|
width: 420px;
|
||||||
|
flex: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.panel-divider {
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(180, 192, 210, 0.1);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.growth-word {
|
||||||
|
position: relative;
|
||||||
|
color: #1BB0CE;
|
||||||
|
}
|
||||||
|
.growth-word::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: #1BB0CE;
|
||||||
|
animation: pulseUnderline 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulseUnderline {
|
||||||
|
0%, 100% { background-color: #1BB0CE; }
|
||||||
|
50% { background-color: rgba(180, 192, 210, 0.6); }
|
||||||
|
}
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.stat::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, #1BB0CE, rgba(180,192,210,1), #1BB0CE);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: statSweep 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.stat:nth-child(2)::before {
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
.stat:nth-child(3)::before {
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
@keyframes statSweep {
|
||||||
|
0% { background-position: 0% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
.stat-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1BB0CE;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(232,232,239,0.3);
|
||||||
|
margin-top: 4px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.testimonial {
|
||||||
|
border-left: 2px solid rgba(27,176,206,0.25);
|
||||||
|
background: rgba(27,176,206,0.06);
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.stars-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.wave-canvas {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: #0a0a0f;
|
||||||
|
}
|
||||||
|
.accent-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, #1BB0CE, rgba(232,232,239,0.15), transparent);
|
||||||
|
animation: pulseAccent 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulseAccent {
|
||||||
|
0%, 100% { opacity: 0.5; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.input-sheen {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.input-sheen::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -10%;
|
||||||
|
left: -100%;
|
||||||
|
width: 60%;
|
||||||
|
height: 120%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
animation: sheenFloat 3.2s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.input-sheen-delayed::before {
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
.input-sheen:focus-within::before {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
@keyframes sheenFloat {
|
||||||
|
0% { left: -100%; }
|
||||||
|
100% { left: 200%; }
|
||||||
|
}
|
||||||
|
.login-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
background: linear-gradient(135deg, #0d0d15, #151520, #0d0d15);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: bgShift 6s ease-in-out infinite;
|
||||||
|
border: 1.5px solid rgba(180,192,210,0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e8e8ef;
|
||||||
|
padding: 0 12px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.login-input::placeholder {
|
||||||
|
color: rgba(232,232,239,0.2);
|
||||||
|
}
|
||||||
|
.login-input:focus {
|
||||||
|
border-color: #1BB0CE;
|
||||||
|
outline: none;
|
||||||
|
background: #111118;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
@keyframes bgShift {
|
||||||
|
0%, 100% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(232,232,239,0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.password-toggle:hover {
|
||||||
|
color: rgba(232,232,239,0.5);
|
||||||
|
}
|
||||||
|
.login-checkbox {
|
||||||
|
accent-color: #1BB0CE;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.auth-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #1BB0CE;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.auth-btn:hover {
|
||||||
|
background: #17a0bc;
|
||||||
|
}
|
||||||
|
.auth-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.auth-btn::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 60%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||||
|
animation: btnSweep 2.2s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes btnSweep {
|
||||||
|
0% { left: -100%; }
|
||||||
|
100% { left: 200%; }
|
||||||
|
}
|
||||||
|
.login-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(232,232,239,0.4);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.body-text { color: rgba(232,232,239,0.5); }
|
||||||
|
.subheading-text { color: rgba(232,232,239,0.38); }
|
||||||
|
.checkbox-text { color: rgba(232,232,239,0.38); }
|
||||||
|
.footer-text { color: rgba(232,232,239,0.2); }
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata, Viewport } from "next"
|
||||||
import { Inter } from "next/font/google"
|
import { Inter } from "next/font/google"
|
||||||
import { ThemeProvider } from "@/providers/theme-provider"
|
import { ThemeProvider } from "@/providers/theme-provider"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
@@ -9,8 +9,13 @@ const inter = Inter({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Coast IT - CRM",
|
title: "CRM",
|
||||||
description: "Customer Relationship Management System",
|
description: "Customer Relationship Management System",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/logo/CompanyMiniLogo.png",
|
icon: "/logo/CompanyMiniLogo.png",
|
||||||
@@ -25,7 +30,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} min-h-screen antialiased`}>
|
<body className={`${inter.variable} min-h-screen antialiased`}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function LoginLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { COMPANY_NAME } from "@/lib/constants"
|
||||||
|
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
const waves = [
|
||||||
|
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
|
||||||
|
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
|
||||||
|
{ a: 12, f: 0.015, s: 0.025, y: 0.28, fill: "rgba(180,192,210,0.06)" },
|
||||||
|
{ a: 26, f: 0.006, s: 0.010, y: 0.65, fill: "rgba(180,192,210,0.15)" },
|
||||||
|
{ a: 10, f: 0.019, s: 0.030, y: 0.20, fill: "rgba(27,176,206,0.10)" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [remember, setRemember] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [counts, setCounts] = useState({ leads: 0, conversion: 0, users: 0 })
|
||||||
|
const counterStarted = useRef(false)
|
||||||
|
const [testimonialIndex, setTestimonialIndex] = useState(0)
|
||||||
|
|
||||||
|
const testimonials = [
|
||||||
|
{
|
||||||
|
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
||||||
|
author: "Marcus Johnson, Sales Lead at Coast IT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
|
||||||
|
author: "Dillen van der Merwe, Madman of Coast IT",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTestimonialIndex((prev) => (prev + 1) % testimonials.length)
|
||||||
|
}, 5000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [testimonials.length])
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
let animId: number
|
||||||
|
let time = 0
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const parent = canvas.parentElement!
|
||||||
|
const rect = parent.getBoundingClientRect()
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = 200 * dpr
|
||||||
|
canvas.style.width = rect.width + "px"
|
||||||
|
canvas.style.height = "200px"
|
||||||
|
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
resize()
|
||||||
|
window.addEventListener("resize", resize)
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const w = canvas.width / (window.devicePixelRatio || 1)
|
||||||
|
const h = 200
|
||||||
|
ctx!.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
for (const wave of waves) {
|
||||||
|
ctx!.beginPath()
|
||||||
|
ctx!.moveTo(0, h)
|
||||||
|
for (let x = 0; x <= w; x++) {
|
||||||
|
const y = wave.y * h + Math.sin(x * wave.f + time * wave.s) * wave.a
|
||||||
|
ctx!.lineTo(x, y)
|
||||||
|
}
|
||||||
|
ctx!.lineTo(w, h)
|
||||||
|
ctx!.closePath()
|
||||||
|
ctx!.fillStyle = wave.fill
|
||||||
|
ctx!.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
time += 1
|
||||||
|
animId = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(draw)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animId)
|
||||||
|
window.removeEventListener("resize", resize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
|
||||||
|
if (canvases.length === 0) return
|
||||||
|
|
||||||
|
const stars = Array.from({ length: 312 }, () => ({
|
||||||
|
x: Math.random(),
|
||||||
|
y: Math.random(),
|
||||||
|
r: 0.2 + Math.random() * 0.6,
|
||||||
|
baseOpacity: 0.12 + Math.random() * 0.43,
|
||||||
|
speed: 0.003 + Math.random() * 0.015,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
driftX: (Math.random() - 0.5) * 0.00003,
|
||||||
|
driftY: (Math.random() - 0.5) * 0.00003,
|
||||||
|
}))
|
||||||
|
|
||||||
|
let animId: number
|
||||||
|
let time = 0
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
for (const c of canvases) {
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const rect = c.getBoundingClientRect()
|
||||||
|
c.width = rect.width * dpr
|
||||||
|
c.height = rect.height * dpr
|
||||||
|
const ctx = c.getContext("2d")
|
||||||
|
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize()
|
||||||
|
const ros = canvases.map((c) => {
|
||||||
|
const ro = new ResizeObserver(() => resize())
|
||||||
|
ro.observe(c.parentElement!)
|
||||||
|
return ro
|
||||||
|
})
|
||||||
|
window.addEventListener("resize", resize)
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
time += 1
|
||||||
|
for (const c of canvases) {
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const w = c.width / dpr
|
||||||
|
const h = c.height / dpr
|
||||||
|
const ctx = c.getContext("2d")
|
||||||
|
if (!ctx) continue
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
for (const star of stars) {
|
||||||
|
const x = ((star.x + time * star.driftX) % 1) * w
|
||||||
|
const y = ((star.y + time * star.driftY) % 1) * h
|
||||||
|
const opacity = star.baseOpacity * (0.5 + 0.5 * Math.sin(time * star.speed + star.phase))
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, y, star.r, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = `rgba(255,255,255,${opacity})`
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
if (star.r > 0.5) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = `rgba(255,255,255,${opacity * 0.06})`
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(draw)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animId)
|
||||||
|
ros.forEach((ro) => ro.disconnect())
|
||||||
|
window.removeEventListener("resize", resize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (counterStarted.current) return
|
||||||
|
counterStarted.current = true
|
||||||
|
const targets = { leads: 1247, conversion: 40, users: 83 }
|
||||||
|
const steps = 150
|
||||||
|
|
||||||
|
const delayTimer = setTimeout(() => {
|
||||||
|
let step = 0
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
step++
|
||||||
|
if (step >= steps) {
|
||||||
|
clearInterval(interval)
|
||||||
|
setCounts(targets)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCounts({
|
||||||
|
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
|
||||||
|
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
|
||||||
|
users: Math.min(Math.floor((targets.users / steps) * step), targets.users),
|
||||||
|
})
|
||||||
|
}, 15)
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
return () => clearTimeout(delayTimer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
router.push("/dashboard")
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
setError(data.error || "Invalid email or password.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Connection error. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-[#0a0a0f]">
|
||||||
|
<div className="left-panel flex flex-1 flex-col p-12">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<img
|
||||||
|
src="/logo/CompanyLogo.png"
|
||||||
|
alt={COMPANY_NAME}
|
||||||
|
className="h-44 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
||||||
|
Your Agency's{" "}
|
||||||
|
<span className="growth-word">Growth</span>{" "}
|
||||||
|
Starts Here
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-[13px] leading-[1.7] max-w-[360px] mx-auto body-text">
|
||||||
|
Manage leads, track conversions, and grow your web development business with our CRM.
|
||||||
|
</p>
|
||||||
|
<div className="stats-row">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-number">{counts.leads.toLocaleString()}</div>
|
||||||
|
<div className="stat-label">leads tracked</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-number">{counts.conversion}%</div>
|
||||||
|
<div className="stat-label">conversion lift</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-number">{counts.users}</div>
|
||||||
|
<div className="stat-label">active users</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 testimonial" style={{ background: "rgba(255,255,255,0.7)", padding: "16px 20px 16px 16px", clipPath: "url(#testimonialClip)" }}>
|
||||||
|
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="testimonialClip" clipPathUnits="objectBoundingBox">
|
||||||
|
<path d="M0,0 L0.92,0 C0.96,0 1,0.03 1,0.08 C1,0.14 0.97,0.22 0.9,0.3 C0.85,0.36 0.83,0.42 0.83,0.5 C0.83,0.58 0.85,0.64 0.9,0.7 C0.97,0.78 1,0.86 1,0.92 C1,0.97 0.96,1 0.92,1 L0,1 Z" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<div className="transition-opacity duration-500" key={testimonialIndex}>
|
||||||
|
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
|
||||||
|
“{testimonials[testimonialIndex].text}”
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-bold mt-2" style={{ color: "#0a0a0f" }}>
|
||||||
|
{testimonials[testimonialIndex].author}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-1.5 mt-3">
|
||||||
|
{testimonials.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTestimonialIndex(i)}
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-300 ${i === testimonialIndex ? "w-5 bg-[#1BB0CE]" : "w-1.5 bg-[#1BB0CE]/30"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas ref={canvasRef} className="wave-canvas" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-divider" />
|
||||||
|
|
||||||
|
<div className="right-panel flex items-center justify-center p-10">
|
||||||
|
<canvas ref={starsCanvasRightRef} className="stars-canvas" />
|
||||||
|
<div className="accent-line" />
|
||||||
|
|
||||||
|
<div className="w-full" style={{ maxWidth: 340 }}>
|
||||||
|
<h2 className="text-[24px] font-bold text-[#e8e8ef] tracking-[-0.3px] mb-[5px]">
|
||||||
|
Welcome back
|
||||||
|
</h2>
|
||||||
|
<p className="text-[13px] mb-[26px] subheading-text">
|
||||||
|
Sign in to your account to continue
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="login-label">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="input-sheen">
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@company.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="login-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-[6px]">
|
||||||
|
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="input-sheen input-sheen-delayed">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="login-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="password-toggle"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
className="login-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-[13px] checkbox-text">
|
||||||
|
Remember me
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="auth-btn" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-[11px] mt-4 footer-text">
|
||||||
|
By signing in, you agree to our{" "}
|
||||||
|
<button type="button" className="text-[#1BB0CE] hover:underline">
|
||||||
|
Terms of Service
|
||||||
|
</button>{" "}
|
||||||
|
and{" "}
|
||||||
|
<button type="button" className="text-[#1BB0CE] hover:underline">
|
||||||
|
Privacy Policy
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
export default function RootPage() {
|
export default function RootPage() {
|
||||||
redirect("/login")
|
redirect("/dashboard")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, Fragment } from "react"
|
||||||
|
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
function linkifyText(text: string) {
|
||||||
|
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||||
|
const parts = text.split(urlRegex)
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
if (part.startsWith("http://") || part.startsWith("https://")) {
|
||||||
|
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a>
|
||||||
|
}
|
||||||
|
return <Fragment key={i}>{part}</Fragment>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIChat() {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [input, setInput] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/ai/jobs")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.jobs?.length) {
|
||||||
|
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
|
||||||
|
setMessages([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `• ${n}`).join("\n")}\n\nWhat would you like to know?`,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
setMessages([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setMessages([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const checkOllama = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: "__ping__" }),
|
||||||
|
})
|
||||||
|
setOllamaStatus(res.status !== 503)
|
||||||
|
} catch {
|
||||||
|
setOllamaStatus(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { checkOllama() }, [])
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
const msg = input.trim()
|
||||||
|
if (!msg || loading) return
|
||||||
|
|
||||||
|
setInput("")
|
||||||
|
setError("")
|
||||||
|
setMessages((prev) => [...prev, { role: "user", content: msg }])
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: msg }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
throw new Error(data.error || "Failed to get response")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: data.response }])
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : "AI service unavailable"
|
||||||
|
setError(errMsg)
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "assistant", content: `⚠️ Error: ${errMsg}. Make sure Ollama is running with the model loaded.` },
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
sendMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{ollamaStatus === false && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 flex-none" />
|
||||||
|
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
|
||||||
|
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||||
|
{msg.role === "assistant" && (
|
||||||
|
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
|
||||||
|
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`max-w-[75%] rounded-lg px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||||
|
msg.role === "user"
|
||||||
|
? "bg-[#1BB0CE] text-white"
|
||||||
|
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{linkifyText(msg.content)}
|
||||||
|
</div>
|
||||||
|
{msg.role === "user" && (
|
||||||
|
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
|
||||||
|
<User className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex gap-3 justify-start">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
|
||||||
|
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#2a2a35] p-4">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-2 text-xs text-red-400 flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask for sales tips..."
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] placeholder-[#6a6a75] resize-none outline-none focus:border-[#1BB0CE]/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={loading || !input.trim()}
|
||||||
|
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Briefcase, ChevronDown, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
job_title: string
|
||||||
|
keywords: string[]
|
||||||
|
industry: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobSelectorProps {
|
||||||
|
onSelect: (job: Job | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobSelector({ onSelect }: JobSelectorProps) {
|
||||||
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<Job | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/ai/jobs")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setJobs(data.jobs || []))
|
||||||
|
.catch(() => setJobs([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelect = (job: Job) => {
|
||||||
|
setSelected(job)
|
||||||
|
setOpen(false)
|
||||||
|
onSelect(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="w-full flex items-center gap-2 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] hover:border-[#1BB0CE]/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Briefcase className="h-4 w-4 text-[#1BB0CE] flex-none" />
|
||||||
|
<span className="flex-1 text-left truncate">
|
||||||
|
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
|
||||||
|
</span>
|
||||||
|
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronDown className="h-3.5 w-3.5 text-[#6a6a75]" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#15151e] border border-[#2a2a35] rounded-lg shadow-xl max-h-60 overflow-y-auto">
|
||||||
|
{jobs.map((job, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(job)}
|
||||||
|
className="w-full text-left px-3 py-2.5 text-sm text-[#c8c8d0] hover:bg-[#1a1a24] hover:text-[#e8e8ef] transition-colors border-b border-[#1a1a24] last:border-0"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{job.job_title}</div>
|
||||||
|
<div className="text-xs text-[#6a6a75] mt-0.5">{job.industry} — {job.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{jobs.length === 0 && !loading && (
|
||||||
|
<div className="px-3 py-4 text-xs text-[#6a6a75] text-center">No job categories loaded</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+15
@@ -36,6 +36,21 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
|||||||
|
|
||||||
const total = data.reduce((s, d) => s + d.value, 0)
|
const total = data.reduce((s, d) => s + d.value, 0)
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lead Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
|
||||||
|
No data for this period
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let cum = 0
|
let cum = 0
|
||||||
const segs = data.map((d) => {
|
const segs = data.map((d) => {
|
||||||
const start = cum
|
const start = cum
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user