3 Commits

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