Files Changed

Security Fixes
File	Change
.env.local	Placeholder JWT secret (rotate to random value: openssl rand -base64 64)
src/middleware.ts	Removed /api/auth/me bypass; API routes return JSON 401 (not 302 redirect)
src/lib/auth.ts	sameSite: "lax" → "strict" (create + destroy cookie); SVG name chars are pre-computed (no injection)
src/lib/avatar.ts	Added sanitizeName() — strips non-alphanumeric/safe chars, max 50 chars
src/app/api/users/route.ts	POST restricted to super_admin; validates role against whitelist ["sales","admin","super_admin","dev"]; validates active defaults to true
src/app/api/users/[id]/route.ts	DELETE restricted to super_admin; blocks self-deletion
src/app/api/leads/[id]/route.ts	GET: non-admin users filtered by assigned_to; PATCH: ownership check + score validated 0-100 range
src/app/api/leads/route.ts	DELETE: ownership + admin check; GET: ownership filter for non-admin users
src/app/api/conversations/[id]/messages/route.ts	GET/POST: verify user is a conversation_participant before access
rust-ai/src/main.rs	CORS: AllowOrigin::list(["localhost:3006", "127.0.0.1:3006"]) (was Any)
Performance Fixes
File	Change
src/app/api/leads/route.ts	Added limit (default 50), offset query params; ownership filter in SQL
src/app/api/users/route.ts	Added limit (default 50), offset query params
src/app/api/conversations/route.ts	Added LIMIT 50
src/app/api/leads/[id]/notes/route.ts	Added limit (default 50), offset query params
src/app/api/conversations/[id]/messages/route.ts	Added limit (default 100), offset, before query params
src/app/(dashboard)/chats/page.tsx	Pruned to 5 cached conversations; useMemo wraps messages/conversation/filteredConversations; otherParticipant moved outside component; added 10MB file size limit; recordingChunksRef cleared on discard
src/app/(dashboard)/leads/[id]/page.tsx	Optimistic status update rolls back on fetch failure
database/migrations/003_chat.sql	Added composite index idx_messages_conversation_created on (conversation_id, created_at DESC)
Code Quality Fixes
File	Change
src/lib/ai.ts	Removed dead getInstructions()/updateInstructions() (called nonexistent /ai/instructions)
src/lib/auth.ts	bcrypt.hash(password, 12) — kept (acceptable, OWASP-recommended)
src/app/login/page.tsx	"Forgot password?" button now shows alert to contact admin (was no-op)
src/components/users/user-form-dialog.tsx	Password min 6 → 8 chars
26 silent catch {} blocks across 16 files (see below)	Added console.warn("...") context messages
This commit is contained in:
Ace
2026-06-23 09:45:30 +02:00
parent d891197d8b
commit 1adc4806fa
51 changed files with 1145 additions and 1052 deletions
+15
View File
@@ -34,9 +34,24 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# runtime data
data/
data/ai/
data/ai/jobs.jsonl
# logs
*.log
*.out
error-log
# vercel
.vercel
# python
browser-use-service/venv/
browser-use-service/__pycache__/
browser-use-service/.env
# typescript
*.tsbuildinfo
next-env.d.ts
+114 -57
View File
@@ -1,4 +1,4 @@
import os, json, asyncio, re, shutil, sqlite3, traceback, urllib.parse
import os, json, asyncio, re, shutil, sqlite3, traceback, urllib.parse, random, time, logging
from datetime import datetime, timedelta
from bs4 import BeautifulSoup
import httpx
@@ -7,14 +7,17 @@ from pydantic import BaseModel
import uvicorn
from playwright.async_api import async_playwright
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
app = FastAPI()
PORT = int(os.getenv("PORT", "3008"))
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
FX_PROFILE = os.getenv('FX_PROFILE', r'C:\Users\USER-PC\AppData\Roaming\Mozilla\Firefox\Profiles\h8p11vlj.default-release')
FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite')
FX_PROFILE = os.getenv('FX_PROFILE', '')
FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite') if FX_PROFILE else ''
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
@@ -22,7 +25,6 @@ HEADERS = {
}
last_scrape_time: datetime | None = None
_pw_browser = None
STRICT_KEYWORDS = [
"website", "web design", "web develop", "web dev",
@@ -65,40 +67,75 @@ FB_SEARCHES = [
"need a site for my business",
]
def get_fb_cookies():
"""Copy Firefox cookie DB and extract Facebook cookies for Chromium injection."""
tmp = os.path.join(os.path.dirname(FX_COOKIE_DB), 'cookies_tmp.sqlite')
async def get_fb_cookies():
if not FX_COOKIE_DB or not os.path.exists(FX_COOKIE_DB):
logger.warning("FX_COOKIE_DB not found or FX_PROFILE not set")
return []
tmp = os.path.join(os.path.dirname(FX_COOKIE_DB), f'cookies_tmp_{os.getpid()}.sqlite')
for attempt in range(3):
try:
shutil.copy2(FX_COOKIE_DB, tmp)
break
except PermissionError:
if attempt < 2:
await asyncio.sleep(0.5)
else:
logger.error("Failed to copy cookie DB after 3 attempts")
return []
try:
if not os.path.exists(FX_COOKIE_DB):
return []
shutil.copy2(FX_COOKIE_DB, tmp)
conn = sqlite3.connect(tmp)
c = conn.cursor()
c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'")
rows = c.fetchall()
conn.close()
os.remove(tmp)
try:
os.remove(tmp)
except Exception:
pass
return [{
"name": name, "value": value,
"domain": host if host.startswith('.') else f'.{host}',
"path": path,
"httpOnly": False, "secure": False, "sameSite": "Lax",
"httpOnly": True, "secure": True, "sameSite": "Lax",
} for name, value, host, path in rows]
except Exception as e:
logger.error("Cookie DB read error: %s", e)
try:
os.remove(tmp)
except Exception:
pass
return []
def _is_real_text(line: str) -> bool:
if not line:
return False
# Count words (sequences of letters)
words = re.findall(r'[A-Za-z]{2,}', line)
return len(words) >= 2
WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
def _parse_fb_date(block: list[str]) -> str:
for l in block:
l = l.strip()
m = re.match(r'(\d+)\s*(h|hr|hrs|hour|hours)\s*ago', l)
if m:
hours = int(m.group(1))
d = datetime.now() - timedelta(hours=hours)
return d.strftime('%Y-%m-%d')
if l.lower() == 'yesterday':
d = datetime.now() - timedelta(days=1)
return d.strftime('%Y-%m-%d')
low = l.lower()
if low in WEEKDAY_ORDER:
day_idx = WEEKDAY_ORDER.index(low)
today_idx = datetime.now().weekday()
days_ago = (today_idx - day_idx) % 7
d = datetime.now() - timedelta(days=days_ago)
return d.strftime('%Y-%m-%d')
for fmt in ['%Y-%m-%d', '%d %B %Y', '%B %d %Y', '%d %b %Y', '%b %d %Y', '%d %b', '%b %d']:
try:
dt = datetime.strptime(l, fmt)
return dt.strftime('%Y-%m-%d')
except ValueError:
pass
return datetime.now().strftime('%Y-%m-%d')
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
"""Parse Facebook search results page text into structured posts."""
lines = [l.strip() for l in raw.split('\n')]
# Split into blocks separated by "Facebook" boilerplate lines (2+ consecutive)
blocks = []
cur = []
fb_run = 0
@@ -111,7 +148,6 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
else:
fb_run = 0
if l and len(l) >= 15:
# Check for real words (not scattered reaction chars)
words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) >= 2:
cur.append(l)
@@ -123,11 +159,9 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
for block in blocks:
if not block:
continue
# Find keyword-matched content in this block
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
if not kw_indices:
continue
# Use first keyword match as anchor, grab context
i = kw_indices[0]
start = max(0, i - 2)
end = min(len(block), i + 5)
@@ -144,29 +178,31 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
"author": block[start] if start < i else '',
"url": url,
"source": "facebook",
"date": datetime.now().strftime('%Y-%m-%d'),
"date": _parse_fb_date(block),
})
return posts
async def search_facebook(page, query: str) -> list[dict]:
"""Search Facebook and extract post leads from raw page text."""
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
# Wait for the page content to fully render (React/XHR loads)
await page.wait_for_timeout(6000)
except:
except Exception as e:
logger.warning("Facebook search navigation failed for '%s': %s", query, e)
return []
raw = await page.evaluate('document.body.innerText')
try:
raw = await page.evaluate('document.body.innerText')
except Exception as e:
logger.warning("Failed to evaluate page text: %s", e)
return []
return _extract_posts_from_text(raw, url)
async def scrape_facebook() -> list[dict]:
"""Launch headless Chromium, inject Firefox Facebook cookies, search for leads."""
all_posts = []
fb_cookies = get_fb_cookies()
fb_cookies = await get_fb_cookies()
if not fb_cookies:
logger.warning("No Facebook cookies available")
return []
try:
async with async_playwright() as pw:
@@ -178,32 +214,34 @@ async def scrape_facebook() -> list[dict]:
for c in fb_cookies:
try:
await context.add_cookies([c])
except:
pass
except Exception as e:
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
page = await context.new_page()
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(5000)
url = page.url
if '/login' in url.lower():
logger.warning("Facebook login page detected — cookies may be expired")
return []
for query in FB_SEARCHES[:6]:
try:
posts = await search_facebook(page, query)
all_posts.extend(posts)
await page.wait_for_timeout(2000)
except:
continue
except Exception:
delay = random.uniform(3, 7)
await page.wait_for_timeout(int(delay * 1000))
except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e)
except Exception as e:
logger.error("Facebook scrape failed: %s", e)
return []
seen = set()
deduped = []
for p in all_posts:
key = p['content'][:100]
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
@@ -221,7 +259,8 @@ async def ask_ollama(prompt: str) -> str:
"options": {"temperature": 0.05, "num_predict": 1024},
})
r.raise_for_status()
return r.json()["message"]["content"]
data = r.json()
return data["message"]["content"]
async def scrape_warriorforum() -> list[dict]:
results = []
@@ -245,8 +284,9 @@ async def scrape_warriorforum() -> list[dict]:
txt = date_div.get_text(strip=True)
m = re.search(r'(\d{10})', txt)
if m:
from datetime import datetime as dtdt
date_str = dtdt.utcfromtimestamp(int(m.group(1))).strftime('%Y-%m-%d')
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,
@@ -255,13 +295,12 @@ async def scrape_warriorforum() -> list[dict]:
"date": date_str
})
except Exception:
traceback.print_exc()
logger.error("WarriorForum scrape failed", exc_info=True)
return results
async def classify_leads(results: list[dict]) -> list[dict]:
if not results:
return []
filtered = []
briefs = [r["title"][:200] for r in results]
prompt = f"""Classify each post as LEAD or NOT.
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
@@ -277,32 +316,50 @@ NOT LEAD:
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
Return a JSON array like ["yes","no","yes"] matching the order above."""
ai_succeeded = False
try:
raw = await ask_ollama(prompt)
raw = raw.strip()
if raw.startswith("```json"): raw = raw[7:]
if raw.startswith("```"): raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0]
# Strip markdown code fences
if raw.startswith("```"):
# Remove opening fence
first_nl = raw.find('\n')
if first_nl != -1:
raw = raw[first_nl + 1:]
# Remove closing fence
if raw.endswith("```"):
raw = raw[:-3]
raw = raw.strip()
answers = json.loads(raw)
if isinstance(answers, list) and len(answers) == len(results):
ai_succeeded = True
filtered = []
for i, ans in enumerate(answers):
if isinstance(ans, str) and ans.lower() == 'yes':
filtered.append(results[i])
except:
pass
if not filtered:
if filtered:
return filtered[:10]
# AI successfully classified but returned all "no" — respect that decision
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
return []
except Exception as e:
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
# Fallback: only use keyword filter when AI failed
if not ai_succeeded:
filtered = []
for r in results:
t = r['title'].lower()
# MUST match a web-dev keyword to be a lead
if not kw_match_strict(t):
continue
# But NOT these (offerings, not requests)
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
'affordable web', 'web hosting', 'free website',
'limited time', 'special offer', 'sign up now',
'offer']):
continue
filtered.append(r)
return filtered[:10]
return filtered[:10]
return []
@app.get("/health")
async def health():
@@ -338,15 +395,15 @@ async def scrape_all():
if wf:
wf = await classify_leads(wf)
results.extend(wf[:5])
except:
pass
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:
pass
except Exception as e:
logger.error("Facebook scrape in /scrape/all failed: %s", e)
return results[:12]
if __name__ == "__main__":
+46 -41
View File
@@ -22,7 +22,7 @@ $$ LANGUAGE plpgsql;
-- 1. AUTHENTICATION & ROLE MANAGEMENT
-- ============================================================================
CREATE TABLE roles (
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
display_name VARCHAR(100),
@@ -35,7 +35,7 @@ CREATE TABLE roles (
CREATE UNIQUE INDEX uq_roles_name ON roles(name);
CREATE TABLE permissions (
CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
resource VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
@@ -46,7 +46,7 @@ CREATE TABLE permissions (
CREATE UNIQUE INDEX uq_permissions ON permissions(resource, action);
CREATE TABLE role_permissions (
CREATE TABLE IF NOT EXISTS role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
granted_by UUID,
@@ -54,7 +54,7 @@ CREATE TABLE role_permissions (
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE users (
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
@@ -83,7 +83,7 @@ CREATE INDEX idx_users_is_active ON users(is_active) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_is_locked ON users(is_locked) WHERE is_locked = TRUE AND deleted_at IS NULL;
CREATE INDEX idx_users_created_by ON users(created_by);
CREATE TABLE user_roles (
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id),
@@ -98,7 +98,7 @@ CREATE INDEX idx_user_roles_role ON user_roles(role_id);
-- 2. BAN & SUSPENSION SYSTEM
-- ============================================================================
CREATE TABLE banned_users (
CREATE TABLE IF NOT EXISTS banned_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
banned_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
banned_by UUID NOT NULL REFERENCES users(id),
@@ -122,7 +122,7 @@ CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
WHERE is_reversed = FALSE;
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
CREATE TABLE suspended_users (
CREATE TABLE IF NOT EXISTS suspended_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suspended_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
suspended_by UUID NOT NULL REFERENCES users(id),
@@ -145,7 +145,7 @@ CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
-- 3. SESSION & LOGIN MANAGEMENT
-- ============================================================================
CREATE TABLE sessions (
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
@@ -161,7 +161,7 @@ CREATE INDEX idx_sessions_user ON sessions(user_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX uq_sessions_token ON sessions(token_hash);
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE;
CREATE TABLE login_attempts (
CREATE TABLE IF NOT EXISTS login_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id),
username_attempted VARCHAR(100) NOT NULL,
@@ -181,7 +181,7 @@ CREATE INDEX idx_login_attempts_time ON login_attempts(attempted_at DESC);
-- 4. CUSTOMER MANAGEMENT
-- ============================================================================
CREATE TABLE customer_statuses (
CREATE TABLE IF NOT EXISTS customer_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -196,7 +196,7 @@ CREATE TABLE customer_statuses (
CREATE UNIQUE INDEX uq_customer_statuses_name ON customer_statuses(name) WHERE deleted_at IS NULL;
CREATE TABLE customers (
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_type VARCHAR(20) NOT NULL,
status_id UUID NOT NULL REFERENCES customer_statuses(id),
@@ -218,7 +218,7 @@ CREATE INDEX idx_customers_score ON customers(score DESC) WHERE deleted_at IS NU
CREATE INDEX idx_customers_tags ON customers USING GIN(tags) WHERE deleted_at IS NULL;
CREATE INDEX idx_customers_created ON customers(created_at DESC) WHERE deleted_at IS NULL;
CREATE TABLE individual_customers (
CREATE TABLE IF NOT EXISTS individual_customers (
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
@@ -233,7 +233,7 @@ CREATE TABLE individual_customers (
CREATE INDEX idx_individual_names ON individual_customers(last_name, first_name);
CREATE TABLE company_customers (
CREATE TABLE IF NOT EXISTS company_customers (
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
company_name VARCHAR(255) NOT NULL,
registration_number VARCHAR(100),
@@ -250,7 +250,7 @@ CREATE TABLE company_customers (
CREATE INDEX idx_company_name ON company_customers(company_name);
CREATE INDEX idx_company_industry ON company_customers(industry);
CREATE TABLE contact_information (
CREATE TABLE IF NOT EXISTS contact_information (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL,
@@ -270,7 +270,7 @@ CREATE INDEX idx_contacts_type ON contact_information(type) WHERE deleted_at IS
CREATE INDEX idx_contacts_value ON contact_information(value) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_contacts_primary ON contact_information(customer_id, type) WHERE is_primary = TRUE AND deleted_at IS NULL;
CREATE TABLE addresses (
CREATE TABLE IF NOT EXISTS addresses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
address_type VARCHAR(20) NOT NULL,
@@ -294,7 +294,7 @@ CREATE INDEX idx_addresses_country ON addresses(country) WHERE deleted_at IS NUL
CREATE INDEX idx_addresses_city ON addresses(city) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_addresses_primary ON addresses(customer_id, address_type) WHERE is_primary = TRUE AND deleted_at IS NULL;
CREATE TABLE customer_notes (
CREATE TABLE IF NOT EXISTS customer_notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id),
@@ -315,7 +315,7 @@ CREATE INDEX idx_notes_pinned ON customer_notes(is_pinned) WHERE is_pinned = TRU
-- 5. CUSTOMER & INCIDENT REPORTS
-- ============================================================================
CREATE TABLE customer_reports (
CREATE TABLE IF NOT EXISTS customer_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
reported_by UUID NOT NULL REFERENCES users(id),
@@ -339,7 +339,7 @@ CREATE INDEX idx_customer_reports_reported_by ON customer_reports(reported_by);
CREATE INDEX idx_customer_reports_status ON customer_reports(status);
CREATE INDEX idx_customer_reports_severity ON customer_reports(severity);
CREATE TABLE incident_reports (
CREATE TABLE IF NOT EXISTS incident_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reported_by UUID NOT NULL REFERENCES users(id),
target_user_id UUID REFERENCES users(id),
@@ -368,7 +368,7 @@ CREATE INDEX idx_incident_reports_severity ON incident_reports(severity);
-- 6. LEAD MANAGEMENT
-- ============================================================================
CREATE TABLE lead_sources (
CREATE TABLE IF NOT EXISTS lead_sources (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -380,7 +380,7 @@ CREATE TABLE lead_sources (
CREATE UNIQUE INDEX uq_lead_sources_name ON lead_sources(name) WHERE deleted_at IS NULL;
CREATE TABLE lead_stages (
CREATE TABLE IF NOT EXISTS lead_stages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -395,7 +395,7 @@ CREATE TABLE lead_stages (
CREATE UNIQUE INDEX uq_lead_stages_name ON lead_stages(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_lead_stages_sort ON lead_stages(sort_order);
CREATE TABLE leads (
CREATE TABLE IF NOT EXISTS leads (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
source_id UUID REFERENCES lead_sources(id),
stage_id UUID NOT NULL REFERENCES lead_stages(id),
@@ -431,7 +431,7 @@ CREATE INDEX idx_leads_converted ON leads(converted_customer_id) WHERE converted
CREATE INDEX idx_leads_email ON leads(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_leads_active ON leads(stage_id, assigned_to) WHERE deleted_at IS NULL AND converted_customer_id IS NULL;
CREATE TABLE lead_conversions (
CREATE TABLE IF NOT EXISTS lead_conversions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
@@ -447,7 +447,7 @@ CREATE INDEX idx_lead_conversions_customer ON lead_conversions(customer_id);
-- 7. PRODUCT CATALOG
-- ============================================================================
CREATE TABLE product_categories (
CREATE TABLE IF NOT EXISTS product_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
@@ -462,7 +462,7 @@ CREATE TABLE product_categories (
CREATE UNIQUE INDEX uq_product_categories_name ON product_categories(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_product_categories_parent ON product_categories(parent_id);
CREATE TABLE products (
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
category_id UUID REFERENCES product_categories(id),
name VARCHAR(255) NOT NULL,
@@ -487,7 +487,7 @@ CREATE INDEX idx_products_active ON products(is_active) WHERE deleted_at IS NULL
-- 8. SALES PIPELINE
-- ============================================================================
CREATE TABLE deal_stages (
CREATE TABLE IF NOT EXISTS deal_stages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -502,7 +502,7 @@ CREATE TABLE deal_stages (
CREATE UNIQUE INDEX uq_deal_stages_name ON deal_stages(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_deal_stages_sort ON deal_stages(sort_order);
CREATE TABLE opportunities (
CREATE TABLE IF NOT EXISTS opportunities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
lead_id UUID REFERENCES leads(id),
@@ -530,7 +530,7 @@ CREATE INDEX idx_opportunities_won ON opportunities(is_won) WHERE deleted_at IS
CREATE INDEX idx_opportunities_created ON opportunities(created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_opportunities_owner_stage ON opportunities(owner_id, stage_id) WHERE deleted_at IS NULL AND is_won IS NULL;
CREATE TABLE opportunity_products (
CREATE TABLE IF NOT EXISTS opportunity_products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id),
@@ -549,7 +549,7 @@ CREATE INDEX idx_opp_products_product ON opportunity_products(product_id);
-- 9. COMMUNICATION TRACKING
-- ============================================================================
CREATE TABLE communication_types (
CREATE TABLE IF NOT EXISTS communication_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
description TEXT,
@@ -561,7 +561,7 @@ CREATE TABLE communication_types (
CREATE UNIQUE INDEX uq_comm_types_name ON communication_types(name);
CREATE TABLE communications (
CREATE TABLE IF NOT EXISTS communications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
@@ -585,7 +585,7 @@ CREATE INDEX idx_communications_type ON communications(type_id) WHERE deleted_at
CREATE INDEX idx_communications_created_by ON communications(created_by) WHERE deleted_at IS NULL;
CREATE INDEX idx_communications_started ON communications(started_at) WHERE deleted_at IS NULL;
CREATE TABLE communication_participants (
CREATE TABLE IF NOT EXISTS communication_participants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
communication_id UUID NOT NULL REFERENCES communications(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
@@ -602,7 +602,7 @@ CREATE INDEX idx_comm_participants_user ON communication_participants(user_id);
-- 10. TASKS AND ACTIVITIES
-- ============================================================================
CREATE TABLE task_priorities (
CREATE TABLE IF NOT EXISTS task_priorities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
color VARCHAR(7),
@@ -613,7 +613,7 @@ CREATE TABLE task_priorities (
CREATE UNIQUE INDEX uq_task_priorities_name ON task_priorities(name);
CREATE TABLE tasks (
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
@@ -645,7 +645,7 @@ CREATE INDEX idx_tasks_reminder ON tasks(reminder_at) WHERE reminder_sent = FALS
-- 11. INVOICE SUPPORT
-- ============================================================================
CREATE TABLE invoice_statuses (
CREATE TABLE IF NOT EXISTS invoice_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
description TEXT,
@@ -657,7 +657,7 @@ CREATE TABLE invoice_statuses (
CREATE UNIQUE INDEX uq_invoice_statuses_name ON invoice_statuses(name);
CREATE TABLE invoices (
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
@@ -689,7 +689,7 @@ CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at
CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
-- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE)
CREATE TABLE invoice_items (
CREATE TABLE IF NOT EXISTS invoice_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id),
@@ -705,7 +705,7 @@ CREATE TABLE invoice_items (
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
CREATE INDEX idx_invoice_items_product ON invoice_items(product_id);
CREATE TABLE payments (
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id),
amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
@@ -729,7 +729,7 @@ CREATE INDEX idx_payments_transaction ON payments(transaction_id) WHERE transact
-- 12. AUDIT LOGGING
-- ============================================================================
CREATE TABLE audit_logs (
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
table_name VARCHAR(100) NOT NULL,
record_id UUID NOT NULL,
@@ -755,7 +755,7 @@ CREATE INDEX idx_audit_session ON audit_logs(session_id);
-- 13. DUPLICATE DETECTION
-- ============================================================================
CREATE TABLE customer_duplicates (
CREATE TABLE IF NOT EXISTS customer_duplicates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
duplicate_customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
@@ -810,11 +810,11 @@ BEGIN
)
LOOP
EXECUTE format(
'CREATE TRIGGER trg_%I_updated_at
'DROP TRIGGER IF EXISTS trg_%I_updated_at ON %I; CREATE TRIGGER trg_%I_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column()',
t, t
t, t, t, t
);
END LOOP;
END;
@@ -885,6 +885,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_enforce_create_user ON users;
CREATE TRIGGER trg_enforce_create_user
BEFORE INSERT ON users
FOR EACH ROW
@@ -935,6 +936,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_enforce_role_assignment ON user_roles;
CREATE TRIGGER trg_enforce_role_assignment
BEFORE INSERT ON user_roles
FOR EACH ROW
@@ -973,7 +975,7 @@ BEGIN
audit_action,
CASE WHEN audit_action IN ('UPDATE', 'DELETE') THEN old_row ELSE NULL END,
CASE WHEN audit_action IN ('CREATE', 'UPDATE') THEN new_row ELSE NULL END,
NULL
NULLIF(current_setting('app.current_user_id', true), '')
);
RETURN COALESCE(NEW, OLD);
@@ -998,6 +1000,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_ban ON banned_users;
CREATE TRIGGER trg_audit_ban
AFTER INSERT OR UPDATE ON banned_users
FOR EACH ROW
@@ -1021,6 +1024,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_suspension ON suspended_users;
CREATE TRIGGER trg_audit_suspension
AFTER INSERT OR UPDATE ON suspended_users
FOR EACH ROW
@@ -1051,6 +1055,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
CREATE TRIGGER trg_audit_login
AFTER INSERT ON login_attempts
FOR EACH ROW
+2
View File
@@ -29,6 +29,8 @@ CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_participants_user_id ON conversation_participants(user_id);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at DESC);
-- Seed conversations between superadmin and other users
INSERT INTO conversations (id, created_at) VALUES
('c0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 hours'),
+4 -6
View File
@@ -4,6 +4,9 @@
-- Usage: psql -U postgres -d crm -f run_all.sql
-- ============================================================================
BEGIN;
\set ON_ERROR_STOP on
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
\i 001_schema.sql
@@ -32,9 +35,4 @@
\i 009_settings.sql
\echo '=== Migration Complete ==='
\echo ''
\echo 'Test accounts created:'
\echo ' superadmin_demo / SuperAdmin@2026'
\echo ' admin_demo / AdminAccess@2026'
\echo ' sales_demo / SalesAccess@2026'
\echo ' dev_demo / DevTesting@2026'
COMMIT;
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true,
ignoreDuringBuilds: false,
},
images: {
remotePatterns: [
+2 -3
View File
@@ -6,12 +6,12 @@
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
"dev:next": "next dev -p 3006",
"dev:precheck": "powershell -NoProfile -Command \"$targetPorts=3001,3006,3008; netstat -ano | Select-String LISTENING | ForEach-Object { $line=$_.ToString(); foreach($p in $targetPorts){ if($line -match ('[:]'+$p+'\\s')){ $foundPid=($line -split '\\s+')[-1]; try{ Stop-Process -Id $foundPid -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$p) }catch{} } } }; exit 0\"",
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
"dev:ollama": "powershell -NoProfile -Command \"if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
"dev:rust": "cd rust-ai && cargo run",
"dev:browser-use": "cd browser-use-service && python main.py",
"build": "next build",
"start": "npm run dev:next",
"start": "next start -p 3006",
"lint": "eslint"
},
"dependencies": {
@@ -37,7 +37,6 @@
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"framer-motion": "^11.15.0",
"jose": "^6.2.3",
"lucide-react": "^0.468.0",
+137 -482
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -6,7 +6,7 @@ description = "AI Sales Assistant backend for Coast IT CRM"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -17,5 +17,5 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.5", features = ["cors"] }
dotenvy = "0.15"
scraper = "0.12"
rand = "0.8"
rand = "0.8"
jsonwebtoken = "9"
-85
View File
@@ -1,85 +0,0 @@
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{error, info, warn};
/// Manages the ai.md self-improvement file.
/// Loaded on startup and periodically refreshed.
pub struct InstructionsManager {
path: PathBuf,
current: RwLock<String>,
}
impl InstructionsManager {
pub fn new(path: PathBuf) -> Self {
let initial = fs::read_to_string(&path).unwrap_or_default();
if initial.is_empty() {
warn!("ai.md is empty or missing at {:?}", path);
} else {
info!("Loaded ai.md ({} bytes)", initial.len());
}
Self {
path,
current: RwLock::new(initial),
}
}
/// Read the current instructions
pub async fn get(&self) -> String {
self.current.read().await.clone()
}
/// Append a new entry to the Improvement Log and optionally update the instructions.
/// Returns the updated full content.
pub async fn update(&self, entry: &str, new_content: Option<&str>) -> Result<String, String> {
if let Some(content) = new_content {
// Full replacement
fs::write(&self.path, content).map_err(|e| format!("Write failed: {}", e))?;
let mut current = self.current.write().await;
*current = content.to_string();
info!("ai.md fully replaced ({} bytes)", content.len());
Ok(content.to_string())
} else {
// Append to Improvement Log only
let mut current = self.current.write().await;
let log_entry = format!("\n- {}{}", chrono::Utc::now().format("%Y-%m-%d %H:%M"), entry);
// Find the Improvement Log section and append
if let Some(pos) = current.rfind("\n## Improvement Log") {
// Find the next section after Improvement Log, or end of file
let after_log = &current[pos..];
if let Some(section_start) = after_log[1..].find("\n## ") {
let insert_at = pos + 1 + section_start;
current.insert_str(insert_at, &format!("{}\n", log_entry));
} else {
current.push_str(&format!("{}\n", log_entry));
}
} else {
current.push_str(&format!("\n## Improvement Log\n{}", log_entry));
}
fs::write(&self.path, &*current).map_err(|e| format!("Write failed: {}", e))?;
info!("ai.md improvement log appended: {}", entry);
Ok(current.clone())
}
}
/// Reload from disk
pub async fn reload(&self) {
match fs::read_to_string(&self.path) {
Ok(content) => {
let len = content.len();
let mut current = self.current.write().await;
*current = content;
info!("ai.md reloaded from disk ({} bytes)", len);
}
Err(e) => error!("Failed to reload ai.md: {}", e),
}
}
}
/// Wrapper for thread-safe sharing
pub type SharedInstructions = Arc<InstructionsManager>;
pub fn create_shared(path: PathBuf) -> SharedInstructions {
Arc::new(InstructionsManager::new(path))
}
+186 -142
View File
@@ -1,20 +1,69 @@
use axum::{
extract::State,
http::Method,
http::{HeaderMap, Method, StatusCode},
routing::{get, post},
Json, Router,
};
use tower_http::cors::{CorsLayer, AllowOrigin, Any};
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use std::collections::HashMap;
use std::fs;
use std::sync::{Arc, Mutex};
use tower_http::cors::{Any, CorsLayer};
use tracing::{error, info};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
use uuid::Uuid;
use rand::Rng;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
// ── JWT Claims ────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct Claims {
#[serde(rename = "userId")]
user_id: String,
role: String,
}
fn verify_jwt(token: &str, secret: &str) -> Option<Claims> {
let key = DecodingKey::from_secret(secret.as_bytes());
let validation = Validation::new(Algorithm::HS256);
decode::<Claims>(token, &key, &validation).ok().map(|d| d.claims)
}
// ── Rate limiter ──────────────────────────────────────────────
struct RateLimiter {
buckets: Mutex<HashMap<String, Vec<u64>>>,
max_requests: usize,
window_secs: u64,
}
impl RateLimiter {
fn new(max_requests: usize, window_secs: u64) -> Self {
Self {
buckets: Mutex::new(HashMap::new()),
max_requests,
window_secs,
}
}
async fn check(&self, key: &str) -> bool {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let mut buckets = self.buckets.lock().await;
let timestamps = buckets.entry(key.to_string()).or_default();
timestamps.retain(|t| now - *t <= self.window_secs);
if timestamps.len() >= self.max_requests {
false
} else {
timestamps.push(now);
true
}
}
}
// ── Shared state ───────────────────────────────────────────────
struct AppState {
@@ -23,6 +72,9 @@ struct AppState {
model: String,
jobs: Vec<Job>,
leads: Arc<Mutex<LeadStore>>,
http_client: reqwest::Client,
jwt_secret: String,
rate_limiter: RateLimiter,
}
#[derive(Debug, Clone, Serialize)]
@@ -47,7 +99,6 @@ impl LeadStore {
}
fn push(&mut self, lead: Lead) {
// Deduplicate by URL
if !self.leads.iter().any(|l| l.url == lead.url) {
self.leads.insert(0, lead);
self.leads.truncate(self.max_size);
@@ -55,9 +106,9 @@ impl LeadStore {
}
fn recent(&self, max_age_secs: u64, limit: usize) -> Vec<Lead> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
self.leads.iter()
.filter(|l| now - l.found_at <= max_age_secs)
.filter(|l| now.saturating_sub(l.found_at) <= max_age_secs)
.take(limit)
.cloned()
.collect()
@@ -75,8 +126,6 @@ struct Job {
#[derive(Debug, Deserialize)]
struct ChatRequest {
message: String,
user_id: String,
user_role: String,
}
#[derive(Debug, Serialize)]
@@ -127,7 +176,23 @@ struct OllamaResponseMessage {
content: String,
}
// ── System prompt builder ─────────────────────────────────────
// ── Helpers ────────────────────────────────────────────────────
fn truncate(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (StatusCode, String)> {
let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()).unwrap_or("");
let token = auth_header.strip_prefix("Bearer ").unwrap_or("");
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
})?;
match claims.role.as_str() {
"sales" | "admin" | "super_admin" => Ok(claims),
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
}
}
fn format_leads_output(leads: &[Lead]) -> String {
if leads.is_empty() {
@@ -138,7 +203,7 @@ fn format_leads_output(leads: &[Lead]) -> String {
.enumerate()
.map(|(i, l)| {
let author = if l.author.is_empty() { "Unknown" } else { &l.author };
let date = if l.date.is_empty() { "Unknown" } else { &l.date[..l.date.len().min(10)] };
let date = truncate(&l.date, 10);
format!(
"{}. {}\n {}\n {}\n {}",
i + 1,
@@ -161,9 +226,7 @@ fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
let lead_summary: Vec<String> = leads
.iter()
.map(|l| {
format!("{} | {} | {}", l.author, l.title, l.url)
})
.map(|l| format!("{} | {} | {}", l.author, l.title, l.url))
.collect();
let lead_summary_str = if lead_summary.is_empty() {
"None yet.".to_string()
@@ -187,62 +250,56 @@ fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
async fn handle_chat(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ChatRequest>,
) -> Result<Json<ChatResponse>, (axum::http::StatusCode, String)> {
// Validate role
match req.user_role.as_str() {
"sales" | "admin" | "super_admin" => {}
_ => return Err((axum::http::StatusCode::FORBIDDEN, "Forbidden".to_string())),
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
let claims = extract_claims(&headers, &state)?;
if !state.rate_limiter.check(&claims.user_id).await {
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
}
// Intercept lead listing requests — scrape on demand then return
let msg_lower = req.message.to_lowercase();
let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
let has_listing = msg_words.iter().any(|w| ["listings", "listing", "leads", "links", "lists"].contains(w));
let has_show = msg_lower.contains("show me") || msg_lower.contains("give me") || msg_lower.contains("pull");
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
// Scrape fresh leads now — call both services
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let client = reqwest::Client::new();
let services = [
"http://localhost:3008/scrape/all".to_string(),
];
for service_url in &services {
if let Ok(resp) = client
.post(service_url)
.timeout(Duration::from_secs(120))
.send()
.await
{
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await {
info!("Scraped {} leads from {}", leads_data.len(), service_url);
let mut store = state.leads.lock().unwrap();
for item in &leads_data {
store.push(Lead {
title: item["title"].as_str().unwrap_or("").chars().take(120).collect(),
url: item["url"].as_str().unwrap_or("").to_string(),
source: item["source"].as_str().unwrap_or("unknown").to_string(),
found_at: now,
author: item["author"].as_str().unwrap_or("").chars().take(60).collect(),
date: item["date"].as_str().unwrap_or("").chars().take(30).collect(),
content: item["content"].as_str().unwrap_or("").chars().take(300).collect(),
});
}
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let service_url = "http://localhost:3008/scrape/all".to_string();
if let Ok(resp) = state.http_client
.post(&service_url)
.timeout(Duration::from_secs(120))
.send()
.await
{
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await {
info!("Scraped {} leads from {}", leads_data.len(), service_url);
let mut store = state.leads.lock().await;
for item in &leads_data {
store.push(Lead {
title: truncate(item["title"].as_str().unwrap_or(""), 120),
url: item["url"].as_str().unwrap_or("").to_string(),
source: item["source"].as_str().unwrap_or("unknown").to_string(),
found_at: now,
author: truncate(item["author"].as_str().unwrap_or(""), 60),
date: truncate(item["date"].as_str().unwrap_or(""), 30),
content: truncate(item["content"].as_str().unwrap_or(""), 300),
});
}
} else {
error!("Scraper service unreachable: {}", service_url);
}
} else {
error!("Scraper service unreachable: {}", service_url);
}
let recent_leads = state.leads.lock().unwrap().recent(604800, 20);
let recent_leads = state.leads.lock().await.recent(604800, 20);
let response = format_leads_output(&recent_leads);
let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(Uuid::parse_str(&req.user_id).unwrap_or(Uuid::nil()))
.bind(&req.user_role)
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&claims.role)
.bind(&req.message)
.bind(&response)
.execute(&state.db)
@@ -250,84 +307,62 @@ async fn handle_chat(
return Ok(Json(ChatResponse { response }));
}
let recent_leads = state.leads.lock().unwrap().recent(604800, 20);
let recent_leads = state.leads.lock().await.recent(604800, 20);
let system_prompt = build_system_prompt(&state.jobs, &recent_leads);
let message_text = req.message.clone();
let ollama_req = OllamaRequest {
model: state.model.clone(),
messages: vec![
OllamaChatMessage {
role: "system".to_string(),
content: system_prompt,
},
OllamaChatMessage {
role: "user".to_string(),
content: req.message.clone(),
},
OllamaChatMessage { role: "system".to_string(), content: system_prompt },
OllamaChatMessage { role: "user".to_string(), content: req.message.clone() },
],
stream: false,
options: OllamaOptions {
temperature: 0.7,
num_predict: 1024,
},
options: OllamaOptions { temperature: 0.7, num_predict: 1024 },
};
let client = reqwest::Client::new();
let resp = client
let resp = state.http_client
.post(format!("{}/api/chat", state.ollama_url))
.json(&ollama_req)
.send()
.await
.map_err(|e| {
error!("Ollama request failed: {}", e);
(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"AI service unavailable".to_string(),
)
(StatusCode::SERVICE_UNAVAILABLE, "AI service unavailable".to_string())
})?;
let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| {
error!("Failed to parse Ollama response: {}", e);
(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"AI response parse error".to_string(),
)
(StatusCode::SERVICE_UNAVAILABLE, "AI response parse error".to_string())
})?;
let response_text = ollama_resp
.message
.map(|m| m.content)
.unwrap_or_default();
let response_text = ollama_resp.message.map(|m| m.content).unwrap_or_default();
// Store in database
let user_id = Uuid::parse_str(&req.user_id).unwrap_or(Uuid::nil());
let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(user_id)
.bind(&req.user_role)
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&claims.role)
.bind(&message_text)
.bind(&response_text)
.execute(&state.db)
.await;
Ok(Json(ChatResponse {
response: response_text,
}))
Ok(Json(ChatResponse { response: response_text }))
}
// ── Jobs handler ───────────────────────────────────────────────
async fn handle_jobs(
State(state): State<Arc<AppState>>,
) -> Json<JobsResponse> {
Json(JobsResponse {
jobs: state.jobs.clone(),
})
headers: HeaderMap,
) -> Result<Json<JobsResponse>, (StatusCode, String)> {
let _claims = extract_claims(&headers, &state)?;
if !state.rate_limiter.check(&_claims.user_id).await {
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
}
Ok(Json(JobsResponse { jobs: state.jobs.clone() }))
}
// ── Health handler ─────────────────────────────────────────────
@@ -354,42 +389,32 @@ async fn main() {
dotenvy::dotenv().ok();
let database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let ollama_url =
std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let port: u16 = std::env::var("AI_PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.expect("AI_PORT must be a number");
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
// Load jobs
let jobs_path = std::env::var("JOBS_PATH").unwrap_or_else(|_| "data/ai/jobs.jsonl".to_string());
let jobs_content = fs::read_to_string(&jobs_path).unwrap_or_default();
let jobs: Vec<Job> = jobs_content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
let jobs: Vec<Job> = jobs_content.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect();
info!(
"Loaded {} job categories, model: {}, Ollama: {}",
jobs.len(),
model,
ollama_url
);
info!("Loaded {} job categories, model: {}, Ollama: {}", jobs.len(), model, ollama_url);
// Connect to database
let db = PgPoolOptions::new()
.max_connections(5)
.max_connections(20)
.connect(&database_url)
.await
.expect("Failed to connect to database");
info!("Connected to PostgreSQL");
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("Failed to build HTTP client");
let lead_store = Arc::new(Mutex::new(LeadStore::new(100)));
let state = Arc::new(AppState {
@@ -398,11 +423,16 @@ async fn main() {
model,
jobs,
leads: lead_store.clone(),
http_client,
jwt_secret,
rate_limiter: RateLimiter::new(30, 60),
});
// CORS layer
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_origin(AllowOrigin::list([
"http://localhost:3006".parse().unwrap(),
"http://127.0.0.1:3006".parse().unwrap(),
]))
.allow_methods([Method::GET, Method::POST])
.allow_headers(Any);
@@ -420,43 +450,57 @@ async fn main() {
.await
.expect("Failed to bind address");
// Start background scrapers
let bg_leads = lead_store.clone();
tokio::task::spawn_blocking(move || {
let bg_client = reqwest::blocking::Client::builder()
let bg_url = "http://localhost:3008/scrape/all".to_string();
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build().ok();
.build()
{
Ok(c) => c,
Err(e) => {
error!("Failed to build background HTTP client: {} — scraper disabled", e);
return;
}
};
loop {
if let Some(ref c) = bg_client {
let urls = vec![
"http://localhost:3008/scrape/all".to_string(),
];
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
for url in &urls {
if let Ok(resp) = c.post(url).send() {
if let Ok(data) = resp.json::<Vec<serde_json::Value>>() {
let mut store = bg_leads.lock().unwrap();
for item in &data {
store.push(Lead {
title: item["title"].as_str().unwrap_or("").chars().take(120).collect(),
url: item["url"].as_str().unwrap_or("").to_string(),
source: item["source"].as_str().unwrap_or("unknown").to_string(),
found_at: now,
author: item["author"].as_str().unwrap_or("").chars().take(60).collect(),
date: item["date"].as_str().unwrap_or("").chars().take(30).collect(),
content: item["content"].as_str().unwrap_or("").chars().take(300).collect(),
});
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
match client.post(&bg_url).send().await {
Ok(resp) => {
if resp.status().is_success() {
match resp.json::<Vec<serde_json::Value>>().await {
Ok(data) => {
let mut store = bg_leads.lock().await;
for item in &data {
store.push(Lead {
title: truncate(item["title"].as_str().unwrap_or(""), 120),
url: item["url"].as_str().unwrap_or("").to_string(),
source: item["source"].as_str().unwrap_or("unknown").to_string(),
found_at: now,
author: truncate(item["author"].as_str().unwrap_or(""), 60),
date: truncate(item["date"].as_str().unwrap_or(""), 30),
content: truncate(item["content"].as_str().unwrap_or(""), 300),
});
}
}
Err(e) => {
warn!("Failed to parse scraper JSON: {}", e);
}
}
} else {
warn!("Scraper returned status: {}", resp.status());
}
}
Err(e) => {
warn!("Scraper request failed: {}", e);
}
}
let delay = rand::thread_rng().gen_range(120..300);
std::thread::sleep(Duration::from_secs(delay));
tokio::time::sleep(Duration::from_secs(delay)).await;
}
});
axum::serve(listener, app)
.await
.expect("Server failed");
}
}
+73 -11
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useRef, useCallback, useEffect } from "react"
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -90,6 +90,9 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
)
}
const MAX_CACHED_CONVERSATIONS = 5
const otherParticipant = (conv: any) => conv.otherUser
export default function ChatsPage() {
const { theme } = useTheme()
const { user } = useUser()
@@ -129,6 +132,8 @@ export default function ChatsPage() {
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const recordingChunksRef = useRef<Blob[]>([])
const resizeStartRef = useRef({ x: 0, width: 0 })
const blobUrlsRef = useRef<string[]>([])
const conversationOrderRef = useRef<string[]>([])
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
@@ -141,12 +146,12 @@ export default function ChatsPage() {
if (!user) return null
const messages = conversationMessages.get(activeChat || "") || []
const conversation = conversations.find((c) => c.id === activeChat)
const otherParticipant = (conv: any) => conv.otherUser
const filteredConversations = searchQuery.trim()
? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
: conversations
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const filteredConversations = useMemo(() => {
if (!searchQuery.trim()) return conversations
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
}, [conversations, searchQuery])
// Fetch conversations from API
useEffect(() => {
@@ -161,7 +166,7 @@ export default function ChatsPage() {
if (convs.length > 0) setActiveChat(convs[0].id)
}
} catch {
// ignore
console.warn("Failed to fetch conversations in chats page")
} finally {
setLoadingChats(false)
}
@@ -172,6 +177,10 @@ export default function ChatsPage() {
// Fetch messages when active chat changes, and mark as read
useEffect(() => {
if (!activeChat) return
conversationOrderRef.current = [
activeChat,
...conversationOrderRef.current.filter((id) => id !== activeChat),
]
const fetchMsgs = async () => {
try {
const res = await fetch(`/api/conversations/${activeChat}/messages`)
@@ -180,6 +189,30 @@ export default function ChatsPage() {
setConversationMessages((prev) => {
const next = new Map(prev)
next.set(activeChat, data.messages || [])
const recent = conversationOrderRef.current.slice(0, MAX_CACHED_CONVERSATIONS)
const recentSet = new Set(recent)
for (const key of next.keys()) {
if (!recentSet.has(key)) next.delete(key)
}
const validMsgIds = new Set<string>()
for (const msgs of next.values()) {
for (const m of msgs) validMsgIds.add(m.id)
}
setVoiceMessages((v) => {
const n = new Map(v)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
return n
})
setMessageAttachments((a) => {
const n = new Map(a)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
return n
})
setReplyMap((r) => {
const n = new Map(r)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
return n
})
return next
})
}
@@ -206,7 +239,7 @@ export default function ChatsPage() {
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
}
} catch { /* ignore */ }
} catch { console.warn("Failed to search users in chats page") }
setSearchingUsers(false)
}, 300)
return () => clearTimeout(timer)
@@ -222,6 +255,14 @@ export default function ChatsPage() {
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
// revoke blob URLs on unmount
useEffect(() => {
return () => {
blobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
blobUrlsRef.current = []
}
}, [])
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
@@ -254,8 +295,16 @@ export default function ChatsPage() {
setTimeout(() => autoResizeTextarea(), 0)
}
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
if (oversized.length > 0) {
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
if (e.target) e.target.value = ""
return
}
setAttachments((prev) => [...prev, ...files])
if (e.target) e.target.value = ""
}
@@ -319,6 +368,7 @@ export default function ChatsPage() {
})
}
} catch {
console.warn("Failed to send message in chats page")
toast.error("Failed to send message")
}
}
@@ -341,7 +391,11 @@ export default function ChatsPage() {
return
}
const fileAttachments = attachments.length > 0
? attachments.map((f) => ({ name: f.name, type: f.type, url: URL.createObjectURL(f) }))
? attachments.map((f) => {
const url = URL.createObjectURL(f)
blobUrlsRef.current.push(url)
return { name: f.name, type: f.type, url }
})
: undefined
if (replyingTo) {
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
@@ -356,6 +410,10 @@ export default function ChatsPage() {
}
const handleDeleteMessage = (messageId: string) => {
const files = messageAttachments.get(messageId)
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
const voice = voiceMessages.get(messageId)
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
setConversationMessages((prev) => {
const next = new Map(prev)
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
@@ -383,6 +441,7 @@ export default function ChatsPage() {
recorder.onstop = () => {
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
const url = URL.createObjectURL(blob)
blobUrlsRef.current.push(url)
addMessageToChat("", { url, duration: recordingDurationRef.current })
stream.getTracks().forEach((t) => t.stop())
setRecordingDuration(0)
@@ -392,6 +451,7 @@ export default function ChatsPage() {
setIsRecording(true)
setIsPaused(false)
} catch {
console.warn("Failed to start recording in chats page")
toast.error("Microphone access denied")
}
}
@@ -425,6 +485,7 @@ export default function ChatsPage() {
setIsPaused(false)
setRecordingDuration(0)
recordingDurationRef.current = 0
recordingChunksRef.current = []
}
useEffect(() => {
@@ -532,7 +593,7 @@ export default function ChatsPage() {
setSearchQuery("")
}
}
} catch { /* ignore */ }
} catch { console.warn("Failed to create conversation in chats page") }
}}
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
>
@@ -911,6 +972,7 @@ export default function ChatsPage() {
toast.success("Message forwarded")
}
} catch {
console.warn("Failed to forward message in chats page")
toast.error("Failed to forward message")
}
}}
+1 -1
View File
@@ -38,7 +38,7 @@ export default function DashboardPage() {
setStats(data)
}
} catch {
// ignore
console.warn("Failed to fetch dashboard stats")
}
}
+4 -1
View File
@@ -3,6 +3,7 @@
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
import { ErrorBoundary } from "@/components/shared/error-boundary"
import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
@@ -29,7 +30,9 @@ export default function DashboardLayout({
return (
<UserProvider>
<NotificationProvider>
<DashboardContent>{children}</DashboardContent>
<ErrorBoundary>
<DashboardContent>{children}</DashboardContent>
</ErrorBoundary>
</NotificationProvider>
</UserProvider>
)
+23 -8
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import Link from "next/link"
import { motion } from "framer-motion"
import { use } from "react"
@@ -26,22 +26,26 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
const [leadNotes, setLeadNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
const fetchNotes = useCallback(async () => {
const notesRes = await fetch(`/api/leads/${id}/notes`)
if (notesRes.ok) setLeadNotes(await notesRes.json())
}, [id])
useEffect(() => {
async function fetchData() {
try {
const [leadRes, notesRes] = await Promise.all([
const [leadRes] = await Promise.all([
fetch(`/api/leads/${id}`),
fetch(`/api/leads/${id}/notes`),
fetchNotes(),
])
if (leadRes.ok) setLead(await leadRes.json())
if (notesRes.ok) setLeadNotes(await notesRes.json())
} catch {
// ignore
console.warn("Failed to fetch lead details")
}
setLoading(false)
}
fetchData()
}, [id])
}, [id, fetchNotes])
if (loading) {
return (
@@ -89,8 +93,19 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
>
<Select
value={lead.status}
onValueChange={(v) => {
onValueChange={async (v) => {
const previousStatus = lead.status
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
try {
const res = await fetch(`/api/leads/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: v }),
})
if (!res.ok) setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
} catch {
setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
}
}}
>
<SelectTrigger className="h-9 w-[140px]">
@@ -125,7 +140,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<NoteForm leadId={lead.id} />
<NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
</motion.div>
<motion.div
+29 -31
View File
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useForm } from "react-hook-form"
@@ -26,9 +26,7 @@ import {
} from "@/components/ui/select"
import { Card, CardContent } from "@/components/ui/card"
import { ArrowLeft } from "lucide-react"
import { Lead, LeadStatus } from "@/types"
import { leads } from "@/data/leads"
import { users } from "@/data/users"
import { User } from "@/types"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
@@ -49,6 +47,14 @@ export default function CreateLeadPage() {
const router = useRouter()
const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false)
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
}, [])
const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema),
@@ -64,35 +70,27 @@ export default function CreateLeadPage() {
},
})
function onSubmit(values: LeadFormValues) {
async function onSubmit(values: LeadFormValues) {
setSaving(true)
const now = new Date().toISOString()
const assignedUserId: string | null = values.assignedUserId === "none" ? null : (values.assignedUserId ?? null)
const assignedUser = assignedUserId ? users.find((u) => u.id === assignedUserId) ?? null : null
const newLead: Lead = {
id: `lead-${String(leads.length + 1).padStart(3, "0")}`,
companyName: values.companyName,
contactName: values.contactName,
email: values.email,
phone: values.phone ?? "",
source: values.source ?? "",
description: values.description ?? "",
status: values.status as LeadStatus,
assignedUserId,
assignedUser,
createdAt: now,
updatedAt: now,
try {
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
}
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to create lead")
const data = await res.json()
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
router.push("/leads")
} catch (e) {
console.error("Create lead error:", e)
} finally {
setSaving(false)
}
leads.unshift(newLead)
addNotification(
"lead_created",
"New Lead Created",
`${newLead.companyName}${newLead.contactName}`,
`/leads/${newLead.id}`
)
router.push("/leads")
}
return (
+1
View File
@@ -39,6 +39,7 @@ export default function ProfilePage() {
toast.error("Failed to save avatar")
}
} catch {
console.warn("Failed to save avatar")
toast.error("Failed to save avatar")
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function UsersPage() {
setUsers(data.users || [])
}
} catch {
// ignore
console.warn("Failed to fetch users in users page")
} finally {
setLoading(false)
}
+5 -1
View File
@@ -16,7 +16,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
const response = await chatWithAI(message, user.id, user.role)
// Forward the JWT from the session cookie to the Rust backend
const sessionCookie = request.cookies.get("session")?.value
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 })
const response = await chatWithAI(message, sessionCookie)
return NextResponse.json({ response })
} catch (error) {
+1
View File
@@ -14,6 +14,7 @@ export async function GET() {
const jobs = await fetchJobs()
return NextResponse.json({ jobs })
} catch {
console.warn("Failed to fetch AI jobs in API route")
return NextResponse.json({ jobs: [] })
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable. Please ensure PostgreSQL is running and configured." },
{ error: "Authentication service unavailable." },
{ status: 503 }
)
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(
_request: NextRequest,
@@ -12,18 +13,40 @@ export async function GET(
const { id } = await params
const { searchParams } = new URL(_request.url)
const limit = parseInt(searchParams.get("limit") || "100", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const before = searchParams.get("before") || ""
// Verify user is a participant
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id]
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
let msgSql = `SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
u.first_name || ' ' || u.last_name AS sender_name,
u.email AS sender_email,
u.avatar_url AS sender_avatar_url
FROM messages m
JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
const msgParams: any[] = [id]
if (before) {
msgSql += ` AND m.created_at < $2`
msgParams.push(before)
}
msgSql += ` ORDER BY m.created_at ASC`
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
msgParams.push(limit, offset)
const [msgResult, otherReadResult] = await Promise.all([
query(
`SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
u.first_name || ' ' || u.last_name AS sender_name,
u.email AS sender_email,
u.avatar_url AS sender_avatar_url
FROM messages m
JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL
ORDER BY m.created_at ASC`,
[id],
),
query(msgSql, msgParams),
query(
`SELECT last_read_at FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
@@ -40,7 +63,7 @@ export async function GET(
conversationId: id,
senderId: row.sender_id,
senderName: row.sender_name,
senderAvatar: row.sender_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.sender_name)}&background=1d4ed8&color=fff&size=128`,
senderAvatar: avatarSvgUrl(row.sender_name),
content: row.content,
timestamp: formatTime(new Date(row.created_at)),
createdAt: row.created_at,
@@ -65,8 +88,17 @@ export async function POST(
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { content } = await request.json()
// Verify user is a participant
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id]
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
const { content } = await request.json()
if (!content?.trim()) {
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
}
+5 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() {
try {
@@ -26,7 +27,8 @@ export async function GET() {
WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
)
ORDER BY c.updated_at DESC`,
ORDER BY c.updated_at DESC
LIMIT 50`,
[user.id],
)
@@ -37,7 +39,7 @@ export async function GET() {
id: row.other_user_id,
name: row.other_user_name,
email: row.other_user_email,
avatar: row.other_user_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.other_user_name)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(row.other_user_name),
},
lastMessage: row.last_message || "",
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "",
@@ -103,7 +105,7 @@ export async function POST(request: NextRequest) {
id: other.id,
name: other.name,
email: other.email,
avatar: other.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(other.name)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(other.name),
},
lastMessage: "",
lastMessageTime: "",
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function getPeriodDateRange(period: string): { start: Date; end: Date } {
const end = new Date()
@@ -158,7 +159,7 @@ export async function GET(request: NextRequest) {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
+8 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
@@ -32,6 +33,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const result = await query(
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
@@ -39,8 +43,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
FROM customer_notes cn
JOIN users u ON u.id = cn.author_id
WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL
ORDER BY cn.created_at DESC`,
[id]
ORDER BY cn.created_at DESC
LIMIT $2 OFFSET $3`,
[id, limit, offset]
)
const notes = result.rows.map((r: any) => ({
@@ -48,7 +53,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
leadId: id,
userId: r.user_id,
authorName: `${r.first_name} ${r.last_name}`,
authorAvatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
authorAvatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
note: r.content,
createdAt: r.created_at,
updatedAt: r.updated_at,
+88 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function stageToStatus(name: string): string {
switch (name) {
@@ -22,6 +23,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
@@ -31,8 +33,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.id = $1 AND l.deleted_at IS NULL`,
[id]
WHERE l.id = $1 AND l.deleted_at IS NULL
AND ($2 = true OR l.assigned_to = $3)`,
[id, isAdmin, user.id]
)
if (result.rows.length === 0) {
@@ -54,7 +57,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
@@ -66,3 +69,85 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 })
}
}
function statusToStageName(status: string): string {
switch (status) {
case "open": return "New"
case "contacted": return "Contacted"
case "pending": return "Qualified"
case "closed": return "Closed Won"
case "ignored": return "Closed Lost"
default: return "New"
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Verify access
const accessCheck = await query(
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
AND ($2 = true OR assigned_to = $3)`,
[id, isAdmin, user.id]
)
if (accessCheck.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.companyName !== undefined) { fields.push(`company_name = $${idx++}`); values.push(body.companyName) }
if (body.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) }
if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) }
if (body.phone !== undefined) { fields.push(`phone = $${idx++}`); values.push(body.phone) }
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
if (body.assignedUserId !== undefined) {
fields.push(`assigned_to = $${idx++}`)
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
}
if (body.status !== undefined) {
const stageName = statusToStageName(body.status)
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
if (stageResult.rows.length > 0) {
fields.push(`stage_id = $${idx++}`)
values.push(stageResult.rows[0].id)
}
}
if (body.score !== undefined) {
const score = Number(body.score)
if (!isFinite(score) || score < 0 || score > 100) {
return NextResponse.json({ error: "Score must be a number between 0 and 100" }, { status: 400 })
}
fields.push(`score = $${idx++}`)
values.push(score)
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
const sql = `UPDATE leads SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING id`
const result = await query(sql, values)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
return NextResponse.json({ success: true, id: result.rows[0].id })
} catch (error) {
console.error("Lead PATCH error:", error)
return NextResponse.json({ error: "Failed to update lead" }, { status: 500 })
}
}
+77 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function stageToStatus(name: string): string {
switch (name) {
@@ -38,6 +39,9 @@ export async function GET(request: NextRequest) {
const search = searchParams.get("search") || ""
const status = searchParams.get("status") || "all"
const period = searchParams.get("period") || "all"
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const isAdmin = user.role === "admin" || user.role === "super_admin"
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
@@ -66,7 +70,16 @@ export async function GET(request: NextRequest) {
paramIdx++
}
if (!isAdmin) {
sql += ` AND l.assigned_to = $${paramIdx}`
params.push(user.id)
paramIdx++
}
sql += ` ORDER BY l.created_at DESC`
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
params.push(limit, offset)
paramIdx += 2
const result = await query(sql, params)
@@ -86,7 +99,7 @@ export async function GET(request: NextRequest) {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
@@ -103,3 +116,66 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const stageResult = await query(
"SELECT id FROM lead_stages WHERE name = $1",
[body.status === "open" ? "New" : "Contacted"]
)
const stageId = stageResult.rows[0]?.id || 1
const result = await query(
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING id`,
[
body.companyName,
body.contactName,
body.email,
body.phone || null,
body.description || null,
body.source || null,
stageId,
body.assignedUserId === "none" ? null : body.assignedUserId,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Leads POST error:", error)
return NextResponse.json({ error: "Failed to create lead" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
const result = await query(
"UPDATE leads SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3) RETURNING id",
[id, isAdmin, user.id]
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Leads DELETE error:", error)
return NextResponse.json({ error: "Failed to delete lead" }, { status: 500 })
}
}
+6
View File
@@ -6,6 +6,9 @@ export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const result = await query(`SELECT * FROM company_settings ORDER BY updated_at DESC LIMIT 1`)
const row = result.rows[0]
@@ -37,6 +40,9 @@ export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const body = await request.json()
+4
View File
@@ -1,10 +1,14 @@
import { NextResponse } from "next/server"
import os from "os"
import { getSessionUser } from "@/lib/auth"
let prevCpu = process.cpuUsage()
let prevTime = Date.now()
export async function GET() {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const now = Date.now()
const elapsed = now - prevTime
const currentCpu = process.cpuUsage()
+7
View File
@@ -8,9 +8,16 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super admins can delete users" }, { status: 403 })
}
const { id } = await params
if (id === sessionUser.id) {
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
}
await query(
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
[id]
+22 -4
View File
@@ -1,9 +1,17 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() {
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const result = await query(
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active AS active, u.created_at, u.avatar_url,
@@ -12,7 +20,9 @@ export async function GET() {
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.deleted_at IS NULL
ORDER BY u.created_at DESC`
ORDER BY u.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
)
const users = result.rows.map((row: any) => ({
id: row.id,
@@ -20,7 +30,7 @@ export async function GET() {
email: row.email,
role: row.role.toLowerCase(),
active: row.active,
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${row.first_name}+${row.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${row.first_name} ${row.last_name}`),
createdAt: row.created_at,
}))
return NextResponse.json({ users }, { status: 200 })
@@ -36,12 +46,20 @@ export async function POST(request: NextRequest) {
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super admins can create users" }, { status: 403 })
}
const { name, email, password, role, active } = await request.json()
if (!name || !email || !password || !role) {
return NextResponse.json({ error: "Name, email, password, and role are required" }, { status: 400 })
}
const validRoles = ["sales", "admin", "super_admin", "dev"]
if (!validRoles.includes(role)) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
}
const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName
@@ -52,7 +70,7 @@ export async function POST(request: NextRequest) {
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active, sessionUser.id]
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
)
const roleId = (
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) {
try {
@@ -29,7 +30,7 @@ export async function GET(request: NextRequest) {
id: row.id,
name: row.name,
email: row.email,
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.name)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(row.name),
}))
return NextResponse.json({ users })
+1 -1
View File
@@ -340,7 +340,7 @@ export default function LoginPage() {
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
Password
</label>
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline">
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}>
Forgot password?
</button>
</div>
+3 -35
View File
@@ -32,11 +32,11 @@ export function AIChat() {
.then((r) => r.json())
.then((data) => {
if (data.jobs?.length) {
const jobNames = data.jobs.map((j: { job_title: string }) => j.job_title).join(", ")
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages([
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help you with tips for targeting: ${jobNames}. What would you like to know?`,
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => ` ${n}`).join("\n")}\n\nWhat would you like to know?`,
},
])
} else {
@@ -56,41 +56,9 @@ export function AIChat() {
},
])
})
fetch("/api/ai/jobs")
.then((r) => r.json())
.then((data) => {
if (data.jobs) {
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages([
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `${n}`).join("\n")}\n\nWhat would you like to know?`,
},
])
}
})
}, [])
useEffect(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
.then((data) => {
if (data.jobs?.length) {
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages((prev) =>
prev.length === 1 && prev[0].role === "assistant"
? [
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `${n}`).join("\n")}\n\nWhat would you like to know?`,
},
]
: prev,
)
}
})
}, [])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
@@ -37,7 +37,7 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
setChartData(data.leadsPerMonth || [])
}
} catch {
// ignore
console.warn("Failed to fetch year data in leads per month chart")
}
}
const thisYear = new Date().getFullYear()
+1 -1
View File
@@ -18,7 +18,7 @@ export function SystemMonitor() {
setRssMB(data.rssMB)
setCpuPct(data.cpuPct)
} catch {
// ignore
console.warn("Failed to fetch system stats")
}
}
+42 -18
View File
@@ -32,7 +32,6 @@ import {
} from "@/components/ui/select"
import { Lead, LeadStatus, User } from "@/types"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider"
const leadFormSchema = z.object({
@@ -52,11 +51,21 @@ interface LeadFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
lead?: Lead | null
onSuccess?: () => void
}
export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps) {
export function LeadFormDialog({ open, onOpenChange, lead, onSuccess }: LeadFormDialogProps) {
const isEditing = !!lead
const { addNotification } = useNotifications()
const [users, setUsers] = useState<User[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
}, [])
const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema),
@@ -98,22 +107,37 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
}
}, [lead, form])
function onSubmit(values: LeadFormValues) {
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId,
async function onSubmit(values: LeadFormValues) {
setSaving(true)
try {
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId,
}
if (isEditing && lead) {
const res = await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to update")
addNotification("lead_status_changed", "Lead Updated", `${values.companyName}`, "#")
} else {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to create")
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, "#")
}
onOpenChange(false)
onSuccess?.()
} catch (e) {
console.error("Lead save error:", e)
} finally {
setSaving(false)
}
if (!isEditing) {
const now = new Date().toISOString()
addNotification(
"lead_created",
"New Lead Created",
`${values.companyName}${values.contactName}`,
"#"
)
}
console.log(payload)
onOpenChange(false)
}
return (
@@ -277,7 +301,7 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit">{isEditing ? "Save Changes" : "Create Lead"}</Button>
<Button type="submit" disabled={saving}>{saving ? "Saving..." : isEditing ? "Save Changes" : "Create Lead"}</Button>
</DialogFooter>
</form>
</Form>
+38 -25
View File
@@ -6,12 +6,15 @@ import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Send } from "lucide-react"
import { useUser } from "@/providers/user-provider"
interface NoteFormProps {
leadId: string
onNoteAdded?: () => void
}
export function NoteForm({ leadId }: NoteFormProps) {
export function NoteForm({ leadId, onNoteAdded }: NoteFormProps) {
const { user } = useUser()
const [note, setNote] = useState("")
const [submitting, setSubmitting] = useState(false)
@@ -25,43 +28,53 @@ export function NoteForm({ leadId }: NoteFormProps) {
body: JSON.stringify({ content: note }),
})
setNote("")
window.location.reload()
onNoteAdded?.()
} catch {
// ignore
console.warn("Failed to add note")
}
setSubmitting(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
return (
<Card>
<CardHeader>
<CardTitle>Add Note</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-4">
<Avatar className="h-10 w-10 shrink-0">
<AvatarImage src="https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=64" />
<AvatarFallback>SC</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-3">
<Textarea
placeholder="Write a note about this lead..."
value={note}
onChange={(e) => setNote(e.target.value)}
className="min-h-[100px] resize-none"
/>
<div className="flex justify-end">
<Button
onClick={handleSubmit}
disabled={!note.trim() || submitting}
className="gap-2"
>
<Send className="h-4 w-4" />
{submitting ? "Adding..." : "Add Note"}
</Button>
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div className="flex gap-4">
<Avatar className="h-10 w-10 shrink-0">
<AvatarImage src={user?.avatar || undefined} />
<AvatarFallback>{user?.name?.charAt(0) || "U"}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-3">
<Textarea
placeholder="Write a note about this lead..."
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleKeyDown}
className="min-h-[100px] resize-none"
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={!note.trim() || submitting}
className="gap-2"
>
<Send className="h-4 w-4" />
{submitting ? "Adding..." : "Add Note"}
</Button>
</div>
</div>
</div>
</div>
</form>
</CardContent>
</Card>
)
@@ -35,7 +35,7 @@ export function CompanySettingsForm() {
setData(json)
}
} catch {
// ignore
console.warn("Failed to load company settings")
} finally {
setLoading(false)
}
@@ -57,6 +57,7 @@ export function CompanySettingsForm() {
toast.error("Failed to save company settings")
}
} catch {
console.warn("Failed to save company settings")
toast.error("Failed to save company settings")
} finally {
setSaving(false)
@@ -98,11 +99,13 @@ export function CompanySettingsForm() {
<Input id="company-address" disabled={loading} value={data.companyAddress} onChange={(e) => update("companyAddress", e.target.value)} />
</div>
</div>
<div className="flex justify-end pt-4">
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={loading || saving}>
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
@@ -45,7 +45,7 @@ export function NotificationSettings() {
setPrefs(data)
}
} catch {
// ignore
console.warn("Failed to load notification preferences")
} finally {
setLoading(false)
}
@@ -67,6 +67,7 @@ export function NotificationSettings() {
toast.error("Failed to save preferences")
}
} catch {
console.warn("Failed to save notification preferences")
toast.error("Failed to save preferences")
} finally {
setSaving(false)
@@ -105,11 +106,13 @@ export function NotificationSettings() {
/>
</div>
))}
<div className="flex justify-end pt-4">
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
@@ -37,7 +37,7 @@ export function UserPreferencesForm() {
setPrefs(json)
}
} catch {
// ignore
console.warn("Failed to load user preferences")
} finally {
setLoading(false)
}
@@ -59,6 +59,7 @@ export function UserPreferencesForm() {
toast.error("Failed to save preferences")
}
} catch {
console.warn("Failed to save user preferences")
toast.error("Failed to save preferences")
} finally {
setSaving(false)
@@ -117,11 +118,13 @@ export function UserPreferencesForm() {
</Select>
</div>
</div>
<div className="flex justify-end pt-4">
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
+50
View File
@@ -0,0 +1,50 @@
"use client"
import { Component, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: { componentStack?: string }) {
console.error("ErrorBoundary caught:", error, info.componentStack)
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<h2 className="text-xl font-bold">Something went wrong</h2>
<p className="text-muted-foreground text-sm max-w-md text-center">
{this.state.error?.message || "An unexpected error occurred"}
</p>
<Button variant="outline" onClick={() => {
this.setState({ hasError: false, error: undefined })
window.location.reload()
}}>
Reload Page
</Button>
</div>
)
}
return this.props.children
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ import { toast } from "sonner";
const createSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
password: z.string().min(8, "Password must be at least 8 characters"),
role: z.string().min(1, "Role is required"),
active: z.boolean(),
});
+1
View File
@@ -32,6 +32,7 @@ export function UsersTable({ data, loading, onUserDeleted }: UsersTableProps) {
toast.success("User deleted")
onUserDeleted?.()
} catch {
console.warn("Failed to delete user")
toast.error("Failed to delete user")
}
}
+5 -23
View File
@@ -1,10 +1,10 @@
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
export async function chatWithAI(message: string, userId: string, userRole: string) {
export async function chatWithAI(message: string, jwtToken: string) {
const res = await fetch(`${AI_SERVICE}/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, user_id: userId, user_role: userRole }),
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message }),
})
if (!res.ok) {
@@ -23,30 +23,11 @@ export async function fetchJobs() {
const data = await res.json()
return data.jobs || []
} catch {
console.warn("Failed to fetch AI jobs")
return []
}
}
export async function getInstructions() {
try {
const res = await fetch(`${AI_SERVICE}/ai/instructions`)
if (!res.ok) return null
const data = await res.json()
return data.success ? data.instructions : null
} catch {
return null
}
}
export async function updateInstructions(entry: string, content?: string) {
const res = await fetch(`${AI_SERVICE}/ai/instructions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry, content }),
})
return res.ok
}
export async function checkAiServiceStatus() {
try {
const res = await fetch(`${AI_SERVICE}/health`)
@@ -54,6 +35,7 @@ export async function checkAiServiceStatus() {
const data = await res.json()
return data.status === "ok"
} catch {
console.warn("Failed to check AI service status")
return false
}
}
+9 -9
View File
@@ -3,9 +3,11 @@ import bcrypt from "bcryptjs";
import { query } from "@/lib/db";
import { cookies } from "next/headers";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production",
);
const RAW_SECRET = process.env.JWT_SECRET;
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5;
@@ -174,7 +176,7 @@ export function mapDbUserToSessionUser(
SUPER_ADMIN: "super_admin",
ADMIN: "admin",
SALES_USER: "sales",
DEVELOPER: "sales",
DEVELOPER: "dev",
};
const avatarUrl = dbUser.avatar_url as string | null
@@ -186,9 +188,7 @@ export function mapDbUserToSessionUser(
firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(),
avatar: avatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(
`${dbUser.first_name}+${dbUser.last_name}`,
)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarUrl || (() => { const f = ((dbUser.first_name as string)?.[0] || '').toUpperCase(); const l = ((dbUser.last_name as string)?.[0] || '').toUpperCase(); return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><rect width="128" height="128" fill="#1d4ed8"/><text x="64" y="80" font-size="48" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${f}${l}</text></svg>`)}` })(),
};
}
@@ -199,7 +199,7 @@ export async function createSession(userId: string, role: string) {
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});
@@ -212,7 +212,7 @@ export async function destroySession() {
cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "strict",
path: "/",
maxAge: 0,
});
+26
View File
@@ -0,0 +1,26 @@
function sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9\s\-'.-]/g, "").slice(0, 50)
}
export function getInitials(name: string): string {
return sanitizeName(name)
.split(/\s+/)
.filter(Boolean)
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
export function avatarSvgUrl(name: string, bg = "1d4ed8", size = 128): string {
const initials = getInitials(name)
const fontSize = Math.round(size * 0.375)
const y = Math.round(size * 0.625)
return `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" fill="#${bg}"/><text x="${Math.round(size / 2)}" y="${y}" font-size="${fontSize}" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${initials}</text></svg>`
)}`
}
export function getAvatarUrl(name: string, avatarUrl: string | null | undefined, bg = "1d4ed8", size = 128): string {
return avatarUrl || avatarSvgUrl(name, bg, size)
}
+6 -1
View File
@@ -1,10 +1,15 @@
import { Pool } from "pg"
const dbUrl = process.env.DATABASE_URL
if (!dbUrl) {
throw new Error("DATABASE_URL environment variable is required")
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
connectionString: dbUrl,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
})
pool.on("error", (err) => {
+11 -8
View File
@@ -2,15 +2,16 @@ import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { jwtVerify } from "jose"
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
)
const RAW_SECRET = process.env.JWT_SECRET
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required")
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [
"/login",
"/api/auth/login",
"/api/auth/logout",
"/api/system",
"/_next/static",
"/_next/image",
"/favicon.ico",
@@ -25,10 +26,6 @@ export async function middleware(request: NextRequest) {
(route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route)
)
if (pathname === "/api/auth/me") {
return NextResponse.next()
}
if (isPublic) {
return NextResponse.next()
}
@@ -36,6 +33,9 @@ export async function middleware(request: NextRequest) {
const sessionCookie = request.cookies.get("session")?.value
if (!sessionCookie) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
@@ -45,6 +45,9 @@ export async function middleware(request: NextRequest) {
await jwtVerify(sessionCookie, JWT_SECRET)
return NextResponse.next()
} catch {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
+6 -6
View File
@@ -30,7 +30,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
setNotifications(data.notifications)
}
} catch {
// ignore
console.warn("Failed to fetch notifications in notification provider")
} finally {
setLoading(false)
}
@@ -51,7 +51,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
setUnreadChatCount(total)
}
} catch {
// ignore
console.warn("Failed to fetch unread conversations in notification provider")
}
}
fetchUnread()
@@ -83,7 +83,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
body: JSON.stringify({ type, title, description, link }),
})
} catch {
// ignore
console.warn("Failed to add notification in notification provider")
}
},
[]
@@ -97,7 +97,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try {
await fetch(`/api/notifications/${id}`, { method: "PATCH" })
} catch {
// ignore
console.warn("Failed to mark notification as read in notification provider")
}
}, [])
@@ -106,7 +106,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try {
await fetch("/api/notifications", { method: "PATCH" })
} catch {
// ignore
console.warn("Failed to mark all notifications as read in notification provider")
}
}, [])
@@ -116,7 +116,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try {
await fetch(`/api/notifications/${id}`, { method: "DELETE" })
} catch {
// ignore
console.warn("Failed to dismiss notification in notification provider")
}
}, [])
+2 -1
View File
@@ -39,6 +39,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
setUser(null)
}
} catch {
console.warn("Failed to fetch user in user provider")
setUser(null)
} finally {
setLoading(false)
@@ -53,7 +54,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
try {
await fetch("/api/auth/logout", { method: "POST" })
} catch {
// Proceed with client-side logout even if API fails
console.warn("Failed to logout in user provider")
}
setUser(null)
router.push("/login")