Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d5bad87bc | |||
| 829fc3b008 | |||
| 215d6739d1 |
@@ -1 +0,0 @@
|
|||||||
rust-ai/target/** linguist-generated=true -diff -merge
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import http from "node:http"
|
|
||||||
import fs from "node:fs"
|
|
||||||
import path from "node:path"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
||||||
const ROOT = path.resolve(__dirname, "..")
|
|
||||||
|
|
||||||
// ── Config from env ─────────────────────────────────────────────
|
|
||||||
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
|
||||||
const HOST = process.env.AI_HOST || "0.0.0.0"
|
|
||||||
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
|
||||||
const MODEL = process.env.AI_MODEL || "sam860/dolphin3-llama3.2:3b"
|
|
||||||
const DATABASE_URL = process.env.DATABASE_URL
|
|
||||||
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
|
||||||
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
|
||||||
|
|
||||||
// ── Job loading ─────────────────────────────────────────────────
|
|
||||||
function loadJobs() {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
|
||||||
const jobs = content
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((l) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(l)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
console.log(`Loaded ${jobs.length} job categories`)
|
|
||||||
return jobs
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ai.md management ────────────────────────────────────────────
|
|
||||||
function readInstructions() {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
|
||||||
} catch {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeInstructions(content) {
|
|
||||||
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendToImprovementLog(entry) {
|
|
||||||
const current = readInstructions()
|
|
||||||
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
|
||||||
const logEntry = `\n- ${timestamp} — ${entry}`
|
|
||||||
const logSection = "\n## Improvement Log"
|
|
||||||
const logIndex = current.indexOf(logSection)
|
|
||||||
if (logIndex !== -1) {
|
|
||||||
const afterLog = current.substring(logIndex + logSection.length)
|
|
||||||
const nextSectionIndex = afterLog.search(/\n## /)
|
|
||||||
if (nextSectionIndex !== -1) {
|
|
||||||
const insertAt = logIndex + logSection.length + nextSectionIndex
|
|
||||||
const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt)
|
|
||||||
writeInstructions(updated)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
writeInstructions(current + logEntry + "\n")
|
|
||||||
return current + logEntry + "\n"
|
|
||||||
}
|
|
||||||
const updated = current + `\n${logSection}\n${logEntry}\n`
|
|
||||||
writeInstructions(updated)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chat handler ────────────────────────────────────────────────
|
|
||||||
async function handleChat(userMessage, userId, userRole) {
|
|
||||||
const jobs = loadedJobs
|
|
||||||
const instructions = readInstructions()
|
|
||||||
|
|
||||||
const jobList = jobs
|
|
||||||
.map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance.
|
|
||||||
|
|
||||||
Available job categories to target:
|
|
||||||
${jobList}
|
|
||||||
|
|
||||||
Current instructions:
|
|
||||||
${instructions}
|
|
||||||
|
|
||||||
Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.`
|
|
||||||
|
|
||||||
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: MODEL,
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemPrompt },
|
|
||||||
{ role: "user", content: userMessage },
|
|
||||||
],
|
|
||||||
stream: false,
|
|
||||||
options: { temperature: 0.7, num_predict: 1024 },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!ollamaRes.ok) {
|
|
||||||
const text = await ollamaRes.text()
|
|
||||||
throw new Error(`Ollama error (${ollamaRes.status}): ${text}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await ollamaRes.json()
|
|
||||||
const responseText = data.message?.content || ""
|
|
||||||
|
|
||||||
// Try to persist to DB (best-effort)
|
|
||||||
try {
|
|
||||||
if (pgPool && userId) {
|
|
||||||
await pgPool.query(
|
|
||||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
|
||||||
[crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// table might not exist, ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseText
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PG pool (lazy init) ────────────────────────────────────────
|
|
||||||
let pgPool = null
|
|
||||||
async function initPg() {
|
|
||||||
if (!DATABASE_URL) return
|
|
||||||
try {
|
|
||||||
const { default: pg } = await import("pg")
|
|
||||||
pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 })
|
|
||||||
await pgPool.query("SELECT 1")
|
|
||||||
console.log("Connected to PostgreSQL")
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Request router ─────────────────────────────────────────────
|
|
||||||
const loadedJobs = loadJobs()
|
|
||||||
|
|
||||||
function sendJSON(res, status, data) {
|
|
||||||
res.writeHead(status, { "Content-Type": "application/json" })
|
|
||||||
res.end(JSON.stringify(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBody(req) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let body = ""
|
|
||||||
req.on("data", (chunk) => (body += chunk))
|
|
||||||
req.on("end", () => {
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(body))
|
|
||||||
} catch {
|
|
||||||
reject(new Error("Invalid JSON"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
req.on("error", reject)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseURL(req) {
|
|
||||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
|
||||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
// CORS
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
res.writeHead(204)
|
|
||||||
res.end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pathname } = parseURL(req)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// GET /health
|
|
||||||
if (req.method === "GET" && pathname === "/health") {
|
|
||||||
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /ai/jobs
|
|
||||||
if (req.method === "GET" && pathname === "/ai/jobs") {
|
|
||||||
return sendJSON(res, 200, { jobs: loadedJobs })
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /ai/instructions
|
|
||||||
if (req.method === "GET" && pathname === "/ai/instructions") {
|
|
||||||
const instructions = readInstructions()
|
|
||||||
return sendJSON(res, 200, { success: true, instructions })
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /ai/instructions
|
|
||||||
if (req.method === "POST" && pathname === "/ai/instructions") {
|
|
||||||
const body = await parseBody(req)
|
|
||||||
if (body.content) {
|
|
||||||
writeInstructions(body.content)
|
|
||||||
} else if (body.entry) {
|
|
||||||
appendToImprovementLog(body.entry)
|
|
||||||
}
|
|
||||||
return sendJSON(res, 200, {
|
|
||||||
success: true,
|
|
||||||
instructions: readInstructions(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /ai/chat
|
|
||||||
if (req.method === "POST" && pathname === "/ai/chat") {
|
|
||||||
const body = await parseBody(req)
|
|
||||||
const { message, user_id, user_role } = body
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
return sendJSON(res, 400, { error: "message is required" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const validRoles = ["sales", "admin", "super_admin"]
|
|
||||||
if (user_role && !validRoles.includes(user_role)) {
|
|
||||||
return sendJSON(res, 403, { error: "Forbidden" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await handleChat(message, user_id || "", user_role || "sales")
|
|
||||||
return sendJSON(res, 200, { response })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 404
|
|
||||||
sendJSON(res, 404, { error: "Not found" })
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Request error:", err)
|
|
||||||
sendJSON(res, 500, { error: err.message })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Start ───────────────────────────────────────────────────────
|
|
||||||
server.listen(PORT, HOST, () => {
|
|
||||||
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
|
|
||||||
console.log(` Model: ${MODEL}`)
|
|
||||||
console.log(` Ollama: ${OLLAMA_URL}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
initPg()
|
|
||||||
@@ -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,34 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS facebook_accounts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
label VARCHAR(100) NOT NULL,
|
|
||||||
profile_path TEXT NOT NULL,
|
|
||||||
cookie_file TEXT NOT NULL,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
last_scrape_at TIMESTAMPTZ,
|
|
||||||
last_success_at TIMESTAMPTZ,
|
|
||||||
last_error_at TIMESTAMPTZ,
|
|
||||||
last_error_message TEXT,
|
|
||||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
|
||||||
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
flagged_at TIMESTAMPTZ,
|
|
||||||
flagged_reason TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fb_accounts_active ON facebook_accounts(is_active);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fb_accounts_flagged ON facebook_accounts(flagged);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS facebook_scrape_logs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
account_id UUID NOT NULL REFERENCES facebook_accounts(id) ON DELETE CASCADE,
|
|
||||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
success BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
leads_found INT NOT NULL DEFAULT 0,
|
|
||||||
error_message TEXT,
|
|
||||||
detected_flag VARCHAR(50),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account ON facebook_scrape_logs(account_id, created_at DESC);
|
|
||||||
@@ -59,8 +59,3 @@ next-env.d.ts
|
|||||||
.git_bak
|
.git_bak
|
||||||
node_modules
|
node_modules
|
||||||
target
|
target
|
||||||
# rust build artifacts
|
|
||||||
rust-ai/target/
|
|
||||||
|
|
||||||
# browser-use-service generated files
|
|
||||||
browser-use-service/*.txt
|
|
||||||
Binary file not shown.
@@ -1,7 +1,9 @@
|
|||||||
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging
|
import os, json, asyncio, re, shutil, sqlite3, traceback, urllib.parse, random, time, logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import FastAPI, Query
|
from bs4 import BeautifulSoup
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
import httpx
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from pydantic import BaseModel
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
@@ -9,20 +11,22 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(me
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["http://localhost:3006", "http://127.0.0.1:3006"],
|
|
||||||
allow_methods=["POST"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
PORT = int(os.getenv("PORT", "3008"))
|
PORT = int(os.getenv("PORT", "3008"))
|
||||||
|
|
||||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
||||||
|
|
||||||
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
||||||
|
FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite') if FX_PROFILE else ''
|
||||||
|
|
||||||
BROAD_KEYWORDS = [
|
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",
|
"website", "web design", "web develop", "web dev",
|
||||||
"build my website", "build a website", "create a website",
|
"build my website", "build a website", "create a website",
|
||||||
"need web", "looking for web", "new website",
|
"need web", "looking for web", "new website",
|
||||||
@@ -30,6 +34,9 @@ BROAD_KEYWORDS = [
|
|||||||
"need a website", "my website", "business website",
|
"need a website", "my website", "business website",
|
||||||
"need a designer", "help with my website",
|
"need a designer", "help with my website",
|
||||||
"redesign", "update my website",
|
"redesign", "update my website",
|
||||||
|
]
|
||||||
|
|
||||||
|
BROAD_KEYWORDS = STRICT_KEYWORDS + [
|
||||||
"looking for", "need a", "need an", "looking to",
|
"looking for", "need a", "need an", "looking to",
|
||||||
"help me build", "create my", "build me",
|
"help me build", "create my", "build me",
|
||||||
"design my", "make me a", "would like",
|
"design my", "make me a", "would like",
|
||||||
@@ -37,6 +44,10 @@ BROAD_KEYWORDS = [
|
|||||||
"want someone", "need help with",
|
"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:
|
def kw_match(text: str) -> bool:
|
||||||
t = text.lower()
|
t = text.lower()
|
||||||
return any(kw in t for kw in BROAD_KEYWORDS)
|
return any(kw in t for kw in BROAD_KEYWORDS)
|
||||||
@@ -56,27 +67,14 @@ FB_SEARCHES = [
|
|||||||
"need a site for my business",
|
"need a site for my business",
|
||||||
]
|
]
|
||||||
|
|
||||||
VIEWPORTS = [
|
async def get_fb_cookies():
|
||||||
{'width': 1280, 'height': 800},
|
if not FX_COOKIE_DB or not os.path.exists(FX_COOKIE_DB):
|
||||||
{'width': 1366, 'height': 768},
|
logger.warning("FX_COOKIE_DB not found or FX_PROFILE not set")
|
||||||
{'width': 1440, 'height': 900},
|
|
||||||
{'width': 1536, 'height': 864},
|
|
||||||
{'width': 1920, 'height': 1080},
|
|
||||||
]
|
|
||||||
|
|
||||||
async def get_fb_cookies(profile_path: str | None = None):
|
|
||||||
cookie_db_path = profile_path or FX_PROFILE
|
|
||||||
if not cookie_db_path:
|
|
||||||
logger.warning("No profile path provided")
|
|
||||||
return []
|
return []
|
||||||
cookie_db = os.path.join(cookie_db_path, 'cookies.sqlite')
|
tmp = os.path.join(os.path.dirname(FX_COOKIE_DB), f'cookies_tmp_{os.getpid()}.sqlite')
|
||||||
if not os.path.exists(cookie_db):
|
|
||||||
logger.warning("Cookie DB not found at %s", cookie_db)
|
|
||||||
return []
|
|
||||||
tmp = os.path.join(os.path.dirname(cookie_db), f'cookies_tmp_{os.getpid()}.sqlite')
|
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
shutil.copy2(cookie_db, tmp)
|
shutil.copy2(FX_COOKIE_DB, tmp)
|
||||||
break
|
break
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
if attempt < 2:
|
if attempt < 2:
|
||||||
@@ -184,95 +182,35 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
})
|
})
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
async def human_scroll(page, steps: int = None, total_delay: float = None):
|
|
||||||
steps = steps or random.randint(2, 5)
|
|
||||||
total_delay = total_delay or random.uniform(6, 18)
|
|
||||||
step_delay = total_delay / steps
|
|
||||||
for i in range(steps):
|
|
||||||
scroll_dist = random.randint(200, 600)
|
|
||||||
await page.evaluate(f"window.scrollBy(0, {scroll_dist})")
|
|
||||||
await page.wait_for_timeout(int(step_delay * 1000))
|
|
||||||
if random.random() < 0.3:
|
|
||||||
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 300)})")
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
|
|
||||||
async def random_idle(page):
|
|
||||||
action = random.random()
|
|
||||||
if action < 0.15:
|
|
||||||
try:
|
|
||||||
elems = await page.query_selector_all('a, span, div[role="button"]')
|
|
||||||
if elems:
|
|
||||||
el = random.choice(elems[:20])
|
|
||||||
box = await el.bounding_box()
|
|
||||||
if box:
|
|
||||||
x = box['x'] + box['width'] / 2 + random.randint(-20, 20)
|
|
||||||
y = box['y'] + box['height'] / 2 + random.randint(-10, 10)
|
|
||||||
await page.mouse.move(x, y, steps=random.randint(20, 40))
|
|
||||||
await page.wait_for_timeout(random.randint(500, 2000))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def search_facebook(page, query: str) -> list[dict]:
|
async def search_facebook(page, query: str) -> list[dict]:
|
||||||
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
await page.wait_for_timeout(6000)
|
||||||
|
except Exception as e:
|
||||||
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
|
logger.warning("Facebook search navigation failed for '%s': %s", query, e)
|
||||||
|
return []
|
||||||
# Accidental overscroll: 10% chance to jump to bottom and back
|
|
||||||
if random.random() < 0.1:
|
|
||||||
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
await page.evaluate("window.scrollBy(0, -300)")
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
|
|
||||||
if random.random() < 0.2:
|
|
||||||
await page.evaluate("window.scrollTo(0, 0)")
|
|
||||||
await page.wait_for_timeout(random.randint(2000, 5000))
|
|
||||||
|
|
||||||
if random.random() < 0.3:
|
|
||||||
await random_idle(page)
|
|
||||||
|
|
||||||
|
try:
|
||||||
raw = await page.evaluate('document.body.innerText')
|
raw = await page.evaluate('document.body.innerText')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Facebook search failed: %s", e)
|
logger.warning("Failed to evaluate page text: %s", e)
|
||||||
return []
|
return []
|
||||||
return _extract_posts_from_text(raw, url)
|
return _extract_posts_from_text(raw, url)
|
||||||
|
|
||||||
def cleanup_chrome():
|
async def scrape_facebook() -> list[dict]:
|
||||||
import subprocess, signal
|
all_posts = []
|
||||||
try:
|
fb_cookies = await get_fb_cookies()
|
||||||
subprocess.run(["taskkill", "/F", "/IM", "chrome-headless-shell.exe"], capture_output=True, timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
|
|
||||||
cleanup_chrome()
|
|
||||||
fb_cookies = await get_fb_cookies(profile_path)
|
|
||||||
if not fb_cookies:
|
if not fb_cookies:
|
||||||
logger.warning("No Facebook cookies available")
|
logger.warning("No Facebook cookies available")
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No cookies available"}
|
return []
|
||||||
try:
|
try:
|
||||||
async with async_playwright() as pw:
|
async with async_playwright() as pw:
|
||||||
browser = await pw.chromium.launch(
|
browser = await pw.chromium.launch(headless=True)
|
||||||
headless=True,
|
|
||||||
args=['--disable-blink-features=AutomationControlled']
|
|
||||||
)
|
|
||||||
viewport = random.choice(VIEWPORTS)
|
|
||||||
context = await browser.new_context(
|
context = await browser.new_context(
|
||||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
||||||
viewport=viewport,
|
viewport={'width': 1280, 'height': 800},
|
||||||
)
|
)
|
||||||
|
|
||||||
await context.add_init_script("""
|
|
||||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
|
||||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
|
||||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
|
||||||
window.chrome = { runtime: {} };
|
|
||||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
|
||||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
|
||||||
""")
|
|
||||||
for c in fb_cookies:
|
for c in fb_cookies:
|
||||||
try:
|
try:
|
||||||
await context.add_cookies([c])
|
await context.add_cookies([c])
|
||||||
@@ -280,57 +218,25 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
|
|||||||
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
|
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
|
||||||
|
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
# Navigate through google first for a legitimate Referer header
|
|
||||||
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
await page.wait_for_timeout(5000)
|
||||||
|
|
||||||
url = page.url
|
url = page.url
|
||||||
if '/login' in url.lower():
|
if '/login' in url.lower():
|
||||||
logger.warning("Facebook login page detected — account flagged")
|
logger.warning("Facebook login page detected — cookies may be expired")
|
||||||
await browser.close()
|
return []
|
||||||
return {"success": False, "leads": [], "flagged": True, "flag_reason": "login_page", "error": "Facebook login page detected"}
|
|
||||||
|
|
||||||
# Browse feed like a human before searching
|
for query in FB_SEARCHES[:6]:
|
||||||
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
|
|
||||||
if random.random() < 0.25:
|
|
||||||
await page.evaluate("window.scrollTo(0, 0)")
|
|
||||||
await page.wait_for_timeout(random.randint(2000, 5000))
|
|
||||||
await human_scroll(page, steps=random.randint(1, 2))
|
|
||||||
|
|
||||||
# False start: 30% chance to idle and leave without scraping (skipped when force=true)
|
|
||||||
if not force and random.random() < 0.3:
|
|
||||||
await page.wait_for_timeout(random.randint(8000, 20000))
|
|
||||||
await browser.close()
|
|
||||||
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
|
||||||
|
|
||||||
all_posts = []
|
|
||||||
searches = random.sample(FB_SEARCHES, k=random.randint(3, 6))
|
|
||||||
for i, query in enumerate(searches):
|
|
||||||
try:
|
try:
|
||||||
posts = await search_facebook(page, query)
|
posts = await search_facebook(page, query)
|
||||||
all_posts.extend(posts)
|
all_posts.extend(posts)
|
||||||
# Between searches: random break with possible small scroll
|
delay = random.uniform(3, 7)
|
||||||
if random.random() < 0.4:
|
|
||||||
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
|
|
||||||
delay = random.uniform(8, 25)
|
|
||||||
await page.wait_for_timeout(int(delay * 1000))
|
await page.wait_for_timeout(int(delay * 1000))
|
||||||
# Tab switch: 15% chance after 2nd-3rd search
|
|
||||||
if i == random.randint(1, 2) and random.random() < 0.15:
|
|
||||||
new_page = await context.new_page()
|
|
||||||
await new_page.goto('https://www.messenger.com/', wait_until='domcontentloaded', timeout=15000)
|
|
||||||
await new_page.wait_for_timeout(random.randint(3000, 8000))
|
|
||||||
await new_page.close()
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Facebook search '%s' failed: %s", query, e)
|
logger.warning("Facebook search '%s' failed: %s", query, e)
|
||||||
|
except Exception as e:
|
||||||
# Idle before closing — human would pause
|
logger.error("Facebook scrape failed: %s", e)
|
||||||
if random.random() < 0.5:
|
return []
|
||||||
await page.wait_for_timeout(random.randint(3000, 10000))
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
seen = set()
|
seen = set()
|
||||||
deduped = []
|
deduped = []
|
||||||
@@ -339,18 +245,9 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
|
|||||||
if key not in seen:
|
if key not in seen:
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
deduped.append(p)
|
deduped.append(p)
|
||||||
|
return deduped[:20]
|
||||||
leads = deduped[:20]
|
|
||||||
if leads:
|
|
||||||
leads = await classify_leads(leads)
|
|
||||||
|
|
||||||
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Facebook scrape failed: %s", e)
|
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
|
|
||||||
|
|
||||||
async def ask_ollama(prompt: str) -> str:
|
async def ask_ollama(prompt: str) -> str:
|
||||||
import httpx
|
|
||||||
async with httpx.AsyncClient(timeout=120) as c:
|
async with httpx.AsyncClient(timeout=120) as c:
|
||||||
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
|
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
|
||||||
"model": CLASSIFY_MODEL,
|
"model": CLASSIFY_MODEL,
|
||||||
@@ -365,6 +262,42 @@ async def ask_ollama(prompt: str) -> str:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
return data["message"]["content"]
|
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]:
|
async def classify_leads(results: list[dict]) -> list[dict]:
|
||||||
if not results:
|
if not results:
|
||||||
return []
|
return []
|
||||||
@@ -387,10 +320,13 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
try:
|
try:
|
||||||
raw = await ask_ollama(prompt)
|
raw = await ask_ollama(prompt)
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
|
# Strip markdown code fences
|
||||||
if raw.startswith("```"):
|
if raw.startswith("```"):
|
||||||
|
# Remove opening fence
|
||||||
first_nl = raw.find('\n')
|
first_nl = raw.find('\n')
|
||||||
if first_nl != -1:
|
if first_nl != -1:
|
||||||
raw = raw[first_nl + 1:]
|
raw = raw[first_nl + 1:]
|
||||||
|
# Remove closing fence
|
||||||
if raw.endswith("```"):
|
if raw.endswith("```"):
|
||||||
raw = raw[:-3]
|
raw = raw[:-3]
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
@@ -403,25 +339,18 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
filtered.append(results[i])
|
filtered.append(results[i])
|
||||||
if filtered:
|
if filtered:
|
||||||
return filtered[:10]
|
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))
|
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("AI classification failed, falling back to keyword filter: %s", 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:
|
if not ai_succeeded:
|
||||||
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",
|
|
||||||
]
|
|
||||||
filtered = []
|
filtered = []
|
||||||
for r in results:
|
for r in results:
|
||||||
t = r['title'].lower()
|
t = r['title'].lower()
|
||||||
if not any(kw in t for kw in strict_keywords):
|
if not kw_match_strict(t):
|
||||||
continue
|
continue
|
||||||
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
|
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',
|
'affordable web', 'web hosting', 'free website',
|
||||||
@@ -436,10 +365,46 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
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")
|
@app.post("/scrape/facebook")
|
||||||
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
|
async def scrape_facebook_endpoint():
|
||||||
result = await scrape_facebook(profile_path, force)
|
results = await scrape_facebook()
|
||||||
return result
|
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__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
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
|
||||||
@@ -126,10 +126,10 @@ separate 1:N child tables.
|
|||||||
|
|
||||||
| Username | Password | Role |
|
| Username | Password | Role |
|
||||||
|----------|----------|------|
|
|----------|----------|------|
|
||||||
| `superadmin_demo` | `[REDACTED]` | SUPER_ADMIN |
|
| `superadmin_demo` | `SuperAdmin@2026` | SUPER_ADMIN |
|
||||||
| `admin_demo` | `[REDACTED]` | ADMIN |
|
| `admin_demo` | `AdminAccess@2026` | ADMIN |
|
||||||
| `sales_demo` | `[REDACTED]` | SALES_USER |
|
| `sales_demo` | `SalesAccess@2026` | SALES_USER |
|
||||||
| `dev_demo` | `[REDACTED]` | DEVELOPER |
|
| `dev_demo` | `DevTesting@2026` | DEVELOPER |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,5 +34,8 @@ BEGIN;
|
|||||||
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
||||||
\i 009_settings.sql
|
\i 009_settings.sql
|
||||||
|
|
||||||
|
\echo '=== Running 010_chat_notifications.sql (Chat Message Notifications) ==='
|
||||||
|
\i 010_chat_notifications.sql
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
+216
-339
@@ -34,7 +34,7 @@
|
|||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "^15.5.19",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.19",
|
"eslint-config-next": "15.0.4",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -360,20 +360,10 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/colour": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -389,13 +379,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -411,13 +401,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -431,9 +421,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
"version": "1.2.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -447,15 +437,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
"version": "1.2.4",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -466,53 +453,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -523,15 +469,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
"version": "1.2.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
||||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -542,15 +485,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
"version": "1.2.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -561,15 +501,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -580,15 +517,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
"version": "1.2.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -599,15 +533,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -620,19 +551,16 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -645,69 +573,16 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
||||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -720,19 +595,16 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -745,19 +617,16 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -770,19 +639,16 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -795,20 +661,20 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-wasm32": {
|
"node_modules/@img/sharp-wasm32": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
||||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/runtime": "^1.7.0"
|
"@emnapi/runtime": "^1.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
@@ -817,29 +683,10 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
||||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -856,13 +703,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -928,29 +774,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.4.tgz",
|
||||||
"integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==",
|
"integrity": "sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.0.4.tgz",
|
||||||
"integrity": "sha512-Ctwb4qYuMbHN/1oXLlTdMchwG8h8Xzwq+wGZZMgF3o6+uwyBKAI2c96bdOsl+C62PaUD0Jkh+QpNkhUeDlam0Q==",
|
"integrity": "sha512-rbsF17XGzHtR7SDWzWpavSfum3/UdnF8bAaisnKwP//si3KWPTedVUsflAdjyK1zW3rweBjbALfKcavFneLGvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-glob": "3.3.1"
|
"fast-glob": "3.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.4.tgz",
|
||||||
"integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==",
|
"integrity": "sha512-QecQXPD0yRHxSXWL5Ff80nD+A56sUXZG9koUsjWJwA2Z0ZgVQfuy7gd0/otjxoOovPVHR2eVEvPMHbtZP+pf9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -960,13 +803,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.4.tgz",
|
||||||
"integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==",
|
"integrity": "sha512-pb7Bye3y1Og3PlCtnz2oO4z+/b3pH2/HSYkLbL0hbVuTGil7fPen8/3pyyLjdiTLcFJ+ymeU3bck5hd4IPFFCA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -976,16 +818,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.4.tgz",
|
||||||
"integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==",
|
"integrity": "sha512-12oSaBFjGpB227VHzoXF3gJoK2SlVGmFJMaBJSu5rbpaoT5OjP5OuCLuR9/jnyBF1BAWMs/boa6mLMoJPRriMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -995,16 +833,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.4.tgz",
|
||||||
"integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==",
|
"integrity": "sha512-QARO88fR/a+wg+OFC3dGytJVVviiYFEyjc/Zzkjn/HevUuJ7qGUUAUYy5PGVWY1YgTzeRYz78akQrVQ8r+sMjw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -1014,16 +848,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.0.4.tgz",
|
||||||
"integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==",
|
"integrity": "sha512-Z50b0gvYiUU1vLzfAMiChV8Y+6u/T2mdfpXPHraqpypP7yIT2UV9YBBhcwYkxujmCvGEcRTVWOj3EP7XW/wUnw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -1033,16 +863,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.0.4.tgz",
|
||||||
"integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==",
|
"integrity": "sha512-7H9C4FAsrTAbA/ENzvFWsVytqRYhaJYKa2B3fyQcv96TkOGVMcvyS6s+sj4jZlacxxTcn7ygaMXUPkEk7b78zw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -1052,13 +878,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.4.tgz",
|
||||||
"integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==",
|
"integrity": "sha512-Z/v3WV5xRaeWlgJzN9r4PydWD8sXV35ywc28W63i37G2jnUgScA4OOgS8hQdiXLxE3gqfSuHTicUhr7931OXPQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -1068,13 +893,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.4.tgz",
|
||||||
"integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
|
"integrity": "sha512-NGLchGruagh8lQpDr98bHLyWJXOBSmkEAfK980OiNBa7vNm6PsNoPvzTfstT78WyOeMRQphEQ455rggd7Eo+Dw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -2055,13 +1879,17 @@
|
|||||||
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
|
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/counter": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
|
||||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
"integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-table": {
|
"node_modules/@tanstack/react-table": {
|
||||||
@@ -3222,6 +3050,17 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
@@ -3397,11 +3236,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1",
|
||||||
|
"color-string": "^1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
},
|
},
|
||||||
@@ -3413,7 +3265,17 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true
|
"devOptional": true
|
||||||
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^1.0.0",
|
||||||
|
"simple-swizzle": "^0.2.2"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
@@ -3744,7 +3606,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -4068,13 +3929,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-next": {
|
"node_modules/eslint-config-next": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.0.4.tgz",
|
||||||
"integrity": "sha512-UZwkuhBCNxVZfo93MSHRDOVNWXooJJGcAUyTAVIp0+9QFhH4SqJxWY0s6Mk9C2kMi777HPMn3dseOrZshWpG9Q==",
|
"integrity": "sha512-97mLaAhbJKVQYXUBBrenRtEUAA6bNDPxWfaFEd6mEhKfpajP4wJrW4l7BUlHuYWxR8oQa9W014qBJpumpJQwWA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "15.5.19",
|
"@next/eslint-plugin-next": "15.0.4",
|
||||||
"@rushstack/eslint-patch": "^1.10.3",
|
"@rushstack/eslint-patch": "^1.10.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
@@ -4082,7 +3942,7 @@
|
|||||||
"eslint-import-resolver-typescript": "^3.5.2",
|
"eslint-import-resolver-typescript": "^3.5.2",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||||
"eslint-plugin-react": "^7.37.0",
|
"eslint-plugin-react": "^7.35.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0"
|
"eslint-plugin-react-hooks": "^5.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -4419,7 +4279,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||||
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodelib/fs.stat": "^2.0.2",
|
"@nodelib/fs.stat": "^2.0.2",
|
||||||
"@nodelib/fs.walk": "^1.2.3",
|
"@nodelib/fs.walk": "^1.2.3",
|
||||||
@@ -4436,7 +4295,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-glob": "^4.0.1"
|
"is-glob": "^4.0.1"
|
||||||
},
|
},
|
||||||
@@ -4950,6 +4808,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arrayish": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/is-async-function": {
|
"node_modules/is-async-function": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
|
||||||
@@ -5679,13 +5543,15 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.19",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.0.4.tgz",
|
||||||
"integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==",
|
"integrity": "sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==",
|
||||||
"license": "MIT",
|
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.19",
|
"@next/env": "15.0.4",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/counter": "0.1.3",
|
||||||
|
"@swc/helpers": "0.5.13",
|
||||||
|
"busboy": "1.6.0",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
"styled-jsx": "5.1.6"
|
"styled-jsx": "5.1.6"
|
||||||
@@ -5697,22 +5563,22 @@
|
|||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.5.19",
|
"@next/swc-darwin-arm64": "15.0.4",
|
||||||
"@next/swc-darwin-x64": "15.5.19",
|
"@next/swc-darwin-x64": "15.0.4",
|
||||||
"@next/swc-linux-arm64-gnu": "15.5.19",
|
"@next/swc-linux-arm64-gnu": "15.0.4",
|
||||||
"@next/swc-linux-arm64-musl": "15.5.19",
|
"@next/swc-linux-arm64-musl": "15.0.4",
|
||||||
"@next/swc-linux-x64-gnu": "15.5.19",
|
"@next/swc-linux-x64-gnu": "15.0.4",
|
||||||
"@next/swc-linux-x64-musl": "15.5.19",
|
"@next/swc-linux-x64-musl": "15.0.4",
|
||||||
"@next/swc-win32-arm64-msvc": "15.5.19",
|
"@next/swc-win32-arm64-msvc": "15.0.4",
|
||||||
"@next/swc-win32-x64-msvc": "15.5.19",
|
"@next/swc-win32-x64-msvc": "15.0.4",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.33.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentelemetry/api": "^1.1.0",
|
"@opentelemetry/api": "^1.1.0",
|
||||||
"@playwright/test": "^1.51.1",
|
"@playwright/test": "^1.41.2",
|
||||||
"babel-plugin-react-compiler": "*",
|
"babel-plugin-react-compiler": "*",
|
||||||
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
"react": "^18.2.0 || 19.0.0-rc-66855b96-20241106 || ^19.0.0",
|
||||||
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
"react-dom": "^18.2.0 || 19.0.0-rc-66855b96-20241106 || ^19.0.0",
|
||||||
"sass": "^1.3.0"
|
"sass": "^1.3.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -6856,16 +6722,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"color": "^4.2.3",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.0.3",
|
||||||
"semver": "^7.7.3"
|
"semver": "^7.6.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
@@ -6874,30 +6739,25 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-darwin-arm64": "0.34.5",
|
"@img/sharp-darwin-arm64": "0.33.5",
|
||||||
"@img/sharp-darwin-x64": "0.34.5",
|
"@img/sharp-darwin-x64": "0.33.5",
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
"@img/sharp-libvips-darwin-arm64": "1.0.4",
|
||||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
"@img/sharp-libvips-darwin-x64": "1.0.4",
|
||||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
"@img/sharp-libvips-linux-arm": "1.0.5",
|
||||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
"@img/sharp-libvips-linux-arm64": "1.0.4",
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
"@img/sharp-libvips-linux-s390x": "1.0.4",
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
"@img/sharp-libvips-linux-x64": "1.0.4",
|
||||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
|
||||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
"@img/sharp-linux-arm": "0.33.5",
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
"@img/sharp-linux-arm64": "0.33.5",
|
||||||
"@img/sharp-linux-arm": "0.34.5",
|
"@img/sharp-linux-s390x": "0.33.5",
|
||||||
"@img/sharp-linux-arm64": "0.34.5",
|
"@img/sharp-linux-x64": "0.33.5",
|
||||||
"@img/sharp-linux-ppc64": "0.34.5",
|
"@img/sharp-linuxmusl-arm64": "0.33.5",
|
||||||
"@img/sharp-linux-riscv64": "0.34.5",
|
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||||
"@img/sharp-linux-s390x": "0.34.5",
|
"@img/sharp-wasm32": "0.33.5",
|
||||||
"@img/sharp-linux-x64": "0.34.5",
|
"@img/sharp-win32-ia32": "0.33.5",
|
||||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
"@img/sharp-win32-x64": "0.33.5"
|
||||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
|
||||||
"@img/sharp-wasm32": "0.34.5",
|
|
||||||
"@img/sharp-win32-arm64": "0.34.5",
|
|
||||||
"@img/sharp-win32-ia32": "0.34.5",
|
|
||||||
"@img/sharp-win32-x64": "0.34.5"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
@@ -7006,6 +6866,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-swizzle": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-arrayish": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "1.7.4",
|
"version": "1.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||||
@@ -7050,6 +6919,14 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
"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: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: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: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 \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
"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": "node ai-server/index.mjs",
|
"dev:rust": "cd rust-ai && cargo run",
|
||||||
"dev:browser-use": "cd browser-use-service && python main.py",
|
"dev:browser-use": "cd browser-use-service && python main.py",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
|
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 |
@@ -15,7 +15,6 @@ use tokio::sync::Mutex;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use chrono::Timelike;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -94,25 +93,6 @@ struct LeadStore {
|
|||||||
max_size: usize,
|
max_size: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ScrapeResponse {
|
|
||||||
success: bool,
|
|
||||||
leads: Vec<ScrapeLead>,
|
|
||||||
flagged: bool,
|
|
||||||
flag_reason: Option<String>,
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
struct ScrapeLead {
|
|
||||||
title: String,
|
|
||||||
url: String,
|
|
||||||
author: String,
|
|
||||||
date: String,
|
|
||||||
content: String,
|
|
||||||
source: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LeadStore {
|
impl LeadStore {
|
||||||
fn new(max_size: usize) -> Self {
|
fn new(max_size: usize) -> Self {
|
||||||
Self { leads: Vec::new(), max_size }
|
Self { leads: Vec::new(), max_size }
|
||||||
@@ -208,7 +188,7 @@ fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (Stat
|
|||||||
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
|
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
|
||||||
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
|
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
|
||||||
})?;
|
})?;
|
||||||
match claims.role.to_lowercase().as_str() {
|
match claims.role.as_str() {
|
||||||
"sales" | "admin" | "super_admin" => Ok(claims),
|
"sales" | "admin" | "super_admin" => Ok(claims),
|
||||||
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
||||||
}
|
}
|
||||||
@@ -286,64 +266,30 @@ async fn handle_chat(
|
|||||||
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
|
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") {
|
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 now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
let base_url = "http://localhost:3008/scrape/facebook";
|
let service_url = "http://localhost:3008/scrape/all".to_string();
|
||||||
use std::fmt::Write;
|
if let Ok(resp) = state.http_client
|
||||||
let mut service_url = base_url.to_string();
|
.post(&service_url)
|
||||||
if let Ok(Some((_, path))) = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
.timeout(Duration::from_secs(120))
|
||||||
"SELECT id, profile_path FROM facebook_accounts \
|
.send()
|
||||||
WHERE is_active = TRUE AND flagged = FALSE \
|
|
||||||
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
|
||||||
)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let encoded: String = path.chars().map(|c| match c {
|
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await {
|
||||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' | '~' => c.to_string(),
|
info!("Scraped {} leads from {}", leads_data.len(), service_url);
|
||||||
_ => format!("%{:02X}", c as u8),
|
|
||||||
}).collect();
|
|
||||||
write!(service_url, "?profile_path={}&force=true", encoded).unwrap();
|
|
||||||
info!("Calling Python scrape at: {}?profile_path=...&force=true", base_url);
|
|
||||||
} else {
|
|
||||||
warn!("No active Facebook account found for on-demand scrape");
|
|
||||||
}
|
|
||||||
let req_builder = state.http_client.post(&service_url);
|
|
||||||
|
|
||||||
match req_builder.send().await {
|
|
||||||
Ok(resp) => {
|
|
||||||
let status = resp.status();
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
info!("Python scrape response ({}): {} bytes", status, body.len());
|
|
||||||
if body.starts_with('{') {
|
|
||||||
match serde_json::from_str::<ScrapeResponse>(&body) {
|
|
||||||
Ok(scrape_resp) => {
|
|
||||||
info!("Scraped {} leads from Facebook", scrape_resp.leads.len());
|
|
||||||
if scrape_resp.leads.is_empty() && scrape_resp.error.is_some() {
|
|
||||||
warn!("Python returned error: {:?}", scrape_resp.error);
|
|
||||||
}
|
|
||||||
let mut store = state.leads.lock().await;
|
let mut store = state.leads.lock().await;
|
||||||
for item in &scrape_resp.leads {
|
for item in &leads_data {
|
||||||
store.push(Lead {
|
store.push(Lead {
|
||||||
title: truncate(&item.title, 120),
|
title: truncate(item["title"].as_str().unwrap_or(""), 120),
|
||||||
url: item.url.clone(),
|
url: item["url"].as_str().unwrap_or("").to_string(),
|
||||||
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
||||||
found_at: now,
|
found_at: now,
|
||||||
author: truncate(&item.author, 60),
|
author: truncate(item["author"].as_str().unwrap_or(""), 60),
|
||||||
date: truncate(&item.date, 30),
|
date: truncate(item["date"].as_str().unwrap_or(""), 30),
|
||||||
content: truncate(&item.content, 300),
|
content: truncate(item["content"].as_str().unwrap_or(""), 300),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to parse Python scrape response: {} body: {}", e, &body[..body.len().min(200)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
warn!("Python returned non-JSON response: {}", &body[..body.len().min(200)]);
|
error!("Scraper service unreachable: {}", service_url);
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Scraper request error: {} - URL: {}", e, base_url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
||||||
@@ -465,7 +411,7 @@ async fn main() {
|
|||||||
info!("Connected to PostgreSQL");
|
info!("Connected to PostgreSQL");
|
||||||
|
|
||||||
let http_client = reqwest::Client::builder()
|
let http_client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300))
|
.timeout(Duration::from_secs(120))
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to build HTTP client");
|
.expect("Failed to build HTTP client");
|
||||||
|
|
||||||
@@ -495,7 +441,7 @@ async fn main() {
|
|||||||
.route("/ai/jobs", get(handle_jobs))
|
.route("/ai/jobs", get(handle_jobs))
|
||||||
.route("/health", get(handle_health))
|
.route("/health", get(handle_health))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state.clone());
|
.with_state(state);
|
||||||
|
|
||||||
let addr = format!("{}:{}", host, port);
|
let addr = format!("{}:{}", host, port);
|
||||||
info!("CRM AI server listening on {}", addr);
|
info!("CRM AI server listening on {}", addr);
|
||||||
@@ -505,11 +451,10 @@ async fn main() {
|
|||||||
.expect("Failed to bind address");
|
.expect("Failed to bind address");
|
||||||
|
|
||||||
let bg_leads = lead_store.clone();
|
let bg_leads = lead_store.clone();
|
||||||
let bg_db = state.db.clone();
|
let bg_url = "http://localhost:3008/scrape/all".to_string();
|
||||||
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300))
|
.timeout(Duration::from_secs(120))
|
||||||
.build()
|
.build()
|
||||||
{
|
{
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -518,125 +463,25 @@ async fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Initial delay to let user on-demand requests take priority
|
|
||||||
tokio::time::sleep(Duration::from_secs(300)).await;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// 10% random cycle skip — makes pattern non-periodic
|
|
||||||
if rand::thread_rng().gen_range(0..100) < 10 {
|
|
||||||
info!("Skipping this scrape cycle (random 10% skip)");
|
|
||||||
let jitter = rand::thread_rng().gen_range(16200..19800);
|
|
||||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip night hours (23:00 – 06:00)
|
|
||||||
let hour = chrono::Local::now().hour();
|
|
||||||
if hour < 6 || hour >= 23 {
|
|
||||||
info!("Night hours ({}) — skipping scrape", hour);
|
|
||||||
let jitter = rand::thread_rng().gen_range(16200..19800);
|
|
||||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
match client.post(&bg_url).send().await {
|
||||||
// Pick next active un-flagged account
|
|
||||||
let account = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
|
||||||
"SELECT id, profile_path FROM facebook_accounts \
|
|
||||||
WHERE is_active = TRUE AND flagged = FALSE \
|
|
||||||
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
|
||||||
)
|
|
||||||
.fetch_optional(&bg_db)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match account {
|
|
||||||
Ok(Some((account_id, profile_path))) => {
|
|
||||||
match client.post(&bg_url).query(&[("profile_path", &profile_path)]).send().await {
|
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
if resp.status().is_success() {
|
if resp.status().is_success() {
|
||||||
match resp.json::<ScrapeResponse>().await {
|
match resp.json::<Vec<serde_json::Value>>().await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let leads_count = data.leads.len() as i32;
|
|
||||||
if data.flagged {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE facebook_accounts SET flagged = TRUE, flagged_at = NOW(), \
|
|
||||||
flagged_reason = $2, last_error_at = NOW(), \
|
|
||||||
last_error_message = $3, consecutive_failures = consecutive_failures + 1 \
|
|
||||||
WHERE id = $1"
|
|
||||||
)
|
|
||||||
.bind(account_id)
|
|
||||||
.bind(&data.flag_reason)
|
|
||||||
.bind(&data.error)
|
|
||||||
.execute(&bg_db)
|
|
||||||
.await;
|
|
||||||
warn!("Facebook account {} flagged: {:?}", account_id, data.flag_reason);
|
|
||||||
let reason = data.flag_reason.as_deref().unwrap_or("unknown");
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"INSERT INTO notifications (user_id, type, title, description, link) \
|
|
||||||
SELECT id, 'warning', 'Facebook Account Flagged', \
|
|
||||||
$1 || ' - ' || COALESCE($2, 'unknown reason'), \
|
|
||||||
NULL \
|
|
||||||
FROM users u JOIN user_roles ur ON ur.user_id = u.id \
|
|
||||||
JOIN roles r ON r.id = ur.role_id \
|
|
||||||
WHERE r.name IN ('ADMIN', 'SUPER_ADMIN')"
|
|
||||||
)
|
|
||||||
.bind(&account_id.to_string())
|
|
||||||
.bind(reason)
|
|
||||||
.execute(&bg_db)
|
|
||||||
.await;
|
|
||||||
} else if data.success {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE facebook_accounts SET last_scrape_at = NOW(), \
|
|
||||||
last_success_at = NOW(), consecutive_failures = 0, \
|
|
||||||
updated_at = NOW() WHERE id = $1"
|
|
||||||
)
|
|
||||||
.bind(account_id)
|
|
||||||
.execute(&bg_db)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut store = bg_leads.lock().await;
|
let mut store = bg_leads.lock().await;
|
||||||
for item in &data.leads {
|
for item in &data {
|
||||||
store.push(Lead {
|
store.push(Lead {
|
||||||
title: truncate(&item.title, 120),
|
title: truncate(item["title"].as_str().unwrap_or(""), 120),
|
||||||
url: item.url.clone(),
|
url: item["url"].as_str().unwrap_or("").to_string(),
|
||||||
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
||||||
found_at: now,
|
found_at: now,
|
||||||
author: truncate(&item.author, 60),
|
author: truncate(item["author"].as_str().unwrap_or(""), 60),
|
||||||
date: truncate(&item.date, 30),
|
date: truncate(item["date"].as_str().unwrap_or(""), 30),
|
||||||
content: truncate(&item.content, 300),
|
content: truncate(item["content"].as_str().unwrap_or(""), 300),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
info!("Scraped {} leads from Facebook account {}", leads_count, account_id);
|
|
||||||
} else {
|
|
||||||
// Increment failures; auto-flag if >= 3 consecutive
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE facebook_accounts SET last_error_at = NOW(), \
|
|
||||||
last_error_message = $2, consecutive_failures = consecutive_failures + 1, \
|
|
||||||
flagged = CASE WHEN consecutive_failures + 1 >= 3 THEN TRUE ELSE flagged END, \
|
|
||||||
flagged_reason = CASE WHEN consecutive_failures + 1 >= 3 THEN 'too_many_failures' ELSE flagged_reason END, \
|
|
||||||
flagged_at = CASE WHEN consecutive_failures + 1 >= 3 THEN NOW() ELSE flagged_at END, \
|
|
||||||
updated_at = NOW() WHERE id = $1"
|
|
||||||
)
|
|
||||||
.bind(account_id)
|
|
||||||
.bind(&data.error)
|
|
||||||
.execute(&bg_db)
|
|
||||||
.await;
|
|
||||||
warn!("Facebook scrape failed for account {}: {:?}", account_id, data.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"INSERT INTO facebook_scrape_logs \
|
|
||||||
(account_id, started_at, completed_at, success, leads_found, error_message, detected_flag) \
|
|
||||||
VALUES ($1, NOW() - interval '5 hours', NOW(), $2, $3, $4, $5)"
|
|
||||||
)
|
|
||||||
.bind(account_id)
|
|
||||||
.bind(data.success && !data.flagged)
|
|
||||||
.bind(leads_count)
|
|
||||||
.bind(&data.error)
|
|
||||||
.bind(&data.flag_reason)
|
|
||||||
.execute(&bg_db)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to parse scraper JSON: {}", e);
|
warn!("Failed to parse scraper JSON: {}", e);
|
||||||
@@ -650,17 +495,8 @@ async fn main() {
|
|||||||
warn!("Scraper request failed: {}", e);
|
warn!("Scraper request failed: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
let delay = rand::thread_rng().gen_range(120..300);
|
||||||
Ok(None) => {
|
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||||
info!("No active Facebook accounts available — skipping scrape cycle");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to query Facebook accounts: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let jitter = rand::thread_rng().gen_range(16200..19800); // 4.5h – 5.5h
|
|
||||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
+4
-11
@@ -62,15 +62,8 @@ export default function DashboardPage() {
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 relative">
|
<div className="space-y-6">
|
||||||
{/* Daily Bugle watermark */}
|
<PageHeader title="Dashboard" description="Overview of your sales pipeline">
|
||||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
|
|
||||||
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
|
||||||
DAILY BUGLE
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
|
|
||||||
<Select value={period} onValueChange={setPeriod}>
|
<Select value={period} onValueChange={setPeriod}>
|
||||||
<SelectTrigger className="h-9 w-[160px]">
|
<SelectTrigger className="h-9 w-[160px]">
|
||||||
<ListFilter className="mr-2 h-4 w-4" />
|
<ListFilter className="mr-2 h-4 w-4" />
|
||||||
@@ -85,7 +78,7 @@ export default function DashboardPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{stats
|
{stats
|
||||||
@@ -96,7 +89,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Analytics</p>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||||
@@ -48,7 +48,7 @@ function stageToStatus(name: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
|
async function fetchLeadsInRange(start: Date, end: Date) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||||
l.notes, l.assigned_to, l.score,
|
l.notes, l.assigned_to, l.score,
|
||||||
@@ -59,11 +59,8 @@ async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmi
|
|||||||
LEFT JOIN users u ON u.id = l.assigned_to
|
LEFT JOIN users u ON u.id = l.assigned_to
|
||||||
WHERE l.deleted_at IS NULL
|
WHERE l.deleted_at IS NULL
|
||||||
AND l.created_at >= $1 AND l.created_at <= $2
|
AND l.created_at >= $1 AND l.created_at <= $2
|
||||||
${isAdmin ? "" : "AND l.assigned_to = $3"}
|
|
||||||
ORDER BY l.created_at DESC`,
|
ORDER BY l.created_at DESC`,
|
||||||
isAdmin
|
[start.toISOString(), end.toISOString()]
|
||||||
? [start.toISOString(), end.toISOString()]
|
|
||||||
: [start.toISOString(), end.toISOString(), userId]
|
|
||||||
)
|
)
|
||||||
return result.rows.map((r: any) => ({
|
return result.rows.map((r: any) => ({
|
||||||
...r,
|
...r,
|
||||||
@@ -121,8 +118,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
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 { searchParams } = new URL(request.url)
|
||||||
const period = searchParams.get("period") || "6months"
|
const period = searchParams.get("period") || "6months"
|
||||||
const yearParam = searchParams.get("year")
|
const yearParam = searchParams.get("year")
|
||||||
@@ -139,8 +134,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [currentLeads, prevLeads] = await Promise.all([
|
const [currentLeads, prevLeads] = await Promise.all([
|
||||||
fetchLeadsInRange(start, end, user.id, isAdmin),
|
fetchLeadsInRange(start, end),
|
||||||
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
fetchLeadsInRange(prevRange.start, prevRange.end),
|
||||||
])
|
])
|
||||||
|
|
||||||
const currentCounts = countStatuses(currentLeads)
|
const currentCounts = countStatuses(currentLeads)
|
||||||
-19
@@ -3,28 +3,12 @@ import { getSessionUser } from "@/lib/auth"
|
|||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
|
|
||||||
const result = await query(
|
|
||||||
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
|
|
||||||
AND (assigned_to = $2 OR EXISTS (
|
|
||||||
SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.role_id
|
|
||||||
WHERE ur.user_id = $2 AND r.name IN ('ADMIN', 'SUPER_ADMIN')
|
|
||||||
))`,
|
|
||||||
[leadId, userId]
|
|
||||||
)
|
|
||||||
return result.rows.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
if (!await checkLeadAccess(id, user.id)) {
|
|
||||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { content } = await request.json()
|
const { content } = await request.json()
|
||||||
if (!content?.trim()) {
|
if (!content?.trim()) {
|
||||||
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
||||||
@@ -49,9 +33,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
if (!await checkLeadAccess(id, user.id)) {
|
|
||||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
|
||||||
}
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
@@ -111,11 +111,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
|
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.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
|
||||||
if (body.assignedUserId !== undefined) {
|
if (body.assignedUserId !== undefined) {
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
|
||||||
if (!isAdmin) {
|
|
||||||
// non-admin cannot reassign
|
|
||||||
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
|
|
||||||
}
|
|
||||||
fields.push(`assigned_to = $${idx++}`)
|
fields.push(`assigned_to = $${idx++}`)
|
||||||
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
||||||
}
|
}
|
||||||
@@ -123,15 +123,6 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
|
||||||
|
|
||||||
// Non-admin users can only assign leads to themselves; admin/super_admin can assign to anyone
|
|
||||||
let assignedUserId = body.assignedUserId
|
|
||||||
if (!isAdmin) {
|
|
||||||
assignedUserId = user.id
|
|
||||||
} else if (assignedUserId === "none" || !assignedUserId) {
|
|
||||||
assignedUserId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const stageResult = await query(
|
const stageResult = await query(
|
||||||
"SELECT id FROM lead_stages WHERE name = $1",
|
"SELECT id FROM lead_stages WHERE name = $1",
|
||||||
@@ -151,7 +142,7 @@ export async function POST(request: NextRequest) {
|
|||||||
body.description || null,
|
body.description || null,
|
||||||
body.source || null,
|
body.source || null,
|
||||||
stageId,
|
stageId,
|
||||||
assignedUserId,
|
body.assignedUserId === "none" ? null : body.assignedUserId,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
+1
-2
@@ -49,8 +49,7 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { type, title, description, link, userId } = await request.json()
|
const { type, title, description, link, userId } = await request.json()
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
const targetUserId = userId || user.id
|
||||||
const targetUserId = (userId && isAdmin) ? userId : user.id
|
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO notifications (user_id, type, title, description, link)
|
`INSERT INTO notifications (user_id, type, title, description, link)
|
||||||
@@ -8,9 +8,6 @@ let prevTime = Date.now()
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const elapsed = now - prevTime
|
const elapsed = now - prevTime
|
||||||
@@ -2,9 +2,6 @@ import { NextRequest, NextResponse } from "next/server"
|
|||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
|
|
||||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -15,18 +12,6 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
|
|
||||||
if (!allowed) {
|
|
||||||
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approximate decoded size: base64 is ~4/3 of original
|
|
||||||
const base64Data = avatar.split(",")[1] || ""
|
|
||||||
const estimatedBytes = Math.round(base64Data.length * 0.75)
|
|
||||||
if (estimatedBytes > MAX_AVATAR_BYTES) {
|
|
||||||
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||||
[avatar, user.id],
|
[avatar, user.id],
|
||||||
@@ -7,9 +7,6 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
@@ -7,9 +7,6 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const currentUser = await getSessionUser()
|
const currentUser = await getSessionUser()
|
||||||
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
if (currentUser.role !== "admin" && currentUser.role !== "super_admin") {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = request.nextUrl.searchParams.get("q") || ""
|
const q = request.nextUrl.searchParams.get("q") || ""
|
||||||
|
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
|
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@keyframes loading {
|
|
||||||
0% { transform: translateX(-100%); }
|
|
||||||
100% { transform: translateX(400%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 210 40% 98%;
|
--background: 210 40% 98%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
@@ -16,7 +9,7 @@
|
|||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: 0 100% 40%;
|
--primary: 221.2 83.2% 53.3%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
@@ -28,15 +21,15 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 0 100% 40%;
|
--ring: 221.2 83.2% 53.3%;
|
||||||
--sidebar: 0 0% 100%;
|
--sidebar: 0 0% 100%;
|
||||||
--sidebar-foreground: 222.2 84% 4.9%;
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
--sidebar-primary: 0 100% 40%;
|
--sidebar-primary: 221.2 83.2% 53.3%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 210 40% 96.1%;
|
--sidebar-accent: 210 40% 96.1%;
|
||||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
||||||
--sidebar-border: 214.3 31.8% 91.4%;
|
--sidebar-border: 214.3 31.8% 91.4%;
|
||||||
--sidebar-ring: 0 100% 40%;
|
--sidebar-ring: 221.2 83.2% 53.3%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +40,7 @@
|
|||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222.2 84% 4.9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 0 100% 53%;
|
--primary: 217.2 91.2% 59.8%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
@@ -59,15 +52,15 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 0 100% 53%;
|
--ring: 224.3 76.3% 48%;
|
||||||
--sidebar: 222.2 84% 4.9%;
|
--sidebar: 222.2 84% 4.9%;
|
||||||
--sidebar-foreground: 210 40% 98%;
|
--sidebar-foreground: 210 40% 98%;
|
||||||
--sidebar-primary: 0 100% 53%;
|
--sidebar-primary: 217.2 91.2% 59.8%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
--sidebar-ring: 0 100% 53%;
|
--sidebar-ring: 224.3 76.3% 48%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ocean {
|
.ocean {
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user