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 files (can opt-in for committing if needed)
.env* .env*
# runtime data
data/
data/ai/
data/ai/jobs.jsonl
# logs
*.log
*.out
error-log
# vercel # vercel
.vercel .vercel
# python
browser-use-service/venv/
browser-use-service/__pycache__/
browser-use-service/.env
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
+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 datetime import datetime, timedelta
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import httpx import httpx
@@ -7,14 +7,17 @@ from pydantic import BaseModel
import uvicorn import uvicorn
from playwright.async_api import async_playwright 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() app = FastAPI()
PORT = int(os.getenv("PORT", "3008")) PORT = int(os.getenv("PORT", "3008"))
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b") CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
FX_PROFILE = os.getenv('FX_PROFILE', r'C:\Users\USER-PC\AppData\Roaming\Mozilla\Firefox\Profiles\h8p11vlj.default-release') FX_PROFILE = os.getenv('FX_PROFILE', '')
FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite') FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite') if FX_PROFILE else ''
HEADERS = { HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', '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 last_scrape_time: datetime | None = None
_pw_browser = None
STRICT_KEYWORDS = [ STRICT_KEYWORDS = [
"website", "web design", "web develop", "web dev", "website", "web design", "web develop", "web dev",
@@ -65,40 +67,75 @@ FB_SEARCHES = [
"need a site for my business", "need a site for my business",
] ]
def get_fb_cookies(): async def get_fb_cookies():
"""Copy Firefox cookie DB and extract Facebook cookies for Chromium injection.""" if not FX_COOKIE_DB or not os.path.exists(FX_COOKIE_DB):
tmp = os.path.join(os.path.dirname(FX_COOKIE_DB), 'cookies_tmp.sqlite') 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: try:
if not os.path.exists(FX_COOKIE_DB):
return []
shutil.copy2(FX_COOKIE_DB, tmp)
conn = sqlite3.connect(tmp) conn = sqlite3.connect(tmp)
c = conn.cursor() c = conn.cursor()
c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'") c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'")
rows = c.fetchall() rows = c.fetchall()
conn.close() conn.close()
os.remove(tmp) try:
os.remove(tmp)
except Exception:
pass
return [{ return [{
"name": name, "value": value, "name": name, "value": value,
"domain": host if host.startswith('.') else f'.{host}', "domain": host if host.startswith('.') else f'.{host}',
"path": path, "path": path,
"httpOnly": False, "secure": False, "sameSite": "Lax", "httpOnly": True, "secure": True, "sameSite": "Lax",
} for name, value, host, path in rows] } for name, value, host, path in rows]
except Exception as e: except Exception as e:
logger.error("Cookie DB read error: %s", e)
try:
os.remove(tmp)
except Exception:
pass
return [] return []
def _is_real_text(line: str) -> bool: WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
if not line:
return False def _parse_fb_date(block: list[str]) -> str:
# Count words (sequences of letters) for l in block:
words = re.findall(r'[A-Za-z]{2,}', line) l = l.strip()
return len(words) >= 2 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]: 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')] lines = [l.strip() for l in raw.split('\n')]
# Split into blocks separated by "Facebook" boilerplate lines (2+ consecutive)
blocks = [] blocks = []
cur = [] cur = []
fb_run = 0 fb_run = 0
@@ -111,7 +148,6 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
else: else:
fb_run = 0 fb_run = 0
if l and len(l) >= 15: if l and len(l) >= 15:
# Check for real words (not scattered reaction chars)
words = re.findall(r'[A-Za-z]{2,}', l) words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) >= 2: if len(words) >= 2:
cur.append(l) cur.append(l)
@@ -123,11 +159,9 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
for block in blocks: for block in blocks:
if not block: if not block:
continue continue
# Find keyword-matched content in this block
kw_indices = [i for i, l in enumerate(block) if kw_match(l)] kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
if not kw_indices: if not kw_indices:
continue continue
# Use first keyword match as anchor, grab context
i = kw_indices[0] i = kw_indices[0]
start = max(0, i - 2) start = max(0, i - 2)
end = min(len(block), i + 5) 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 '', "author": block[start] if start < i else '',
"url": url, "url": url,
"source": "facebook", "source": "facebook",
"date": datetime.now().strftime('%Y-%m-%d'), "date": _parse_fb_date(block),
}) })
return posts return posts
async def search_facebook(page, query: str) -> list[dict]: 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)}' url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try: try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000) await page.goto(url, wait_until='domcontentloaded', timeout=30000)
# Wait for the page content to fully render (React/XHR loads)
await page.wait_for_timeout(6000) await page.wait_for_timeout(6000)
except: except Exception as e:
logger.warning("Facebook search navigation failed for '%s': %s", query, e)
return [] 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) return _extract_posts_from_text(raw, url)
async def scrape_facebook() -> list[dict]: async def scrape_facebook() -> list[dict]:
"""Launch headless Chromium, inject Firefox Facebook cookies, search for leads."""
all_posts = [] all_posts = []
fb_cookies = get_fb_cookies() fb_cookies = await get_fb_cookies()
if not fb_cookies: if not fb_cookies:
logger.warning("No Facebook cookies available")
return [] return []
try: try:
async with async_playwright() as pw: async with async_playwright() as pw:
@@ -178,32 +214,34 @@ async def scrape_facebook() -> list[dict]:
for c in fb_cookies: for c in fb_cookies:
try: try:
await context.add_cookies([c]) await context.add_cookies([c])
except: except Exception as e:
pass logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
page = await context.new_page() page = await context.new_page()
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000) await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(5000) await page.wait_for_timeout(5000)
url = page.url url = page.url
if '/login' in url.lower(): if '/login' in url.lower():
logger.warning("Facebook login page detected — cookies may be expired")
return [] return []
for query in FB_SEARCHES[:6]: for query in FB_SEARCHES[:6]:
try: try:
posts = await search_facebook(page, query) posts = await search_facebook(page, query)
all_posts.extend(posts) all_posts.extend(posts)
await page.wait_for_timeout(2000) delay = random.uniform(3, 7)
except: await page.wait_for_timeout(int(delay * 1000))
continue except Exception as e:
except Exception: logger.warning("Facebook search '%s' failed: %s", query, e)
except Exception as e:
logger.error("Facebook scrape failed: %s", e)
return [] return []
seen = set() seen = set()
deduped = [] deduped = []
for p in all_posts: for p in all_posts:
key = p['content'][:100] key = p.get('content', '')[:100]
if key not in seen: if key not in seen:
seen.add(key) seen.add(key)
deduped.append(p) deduped.append(p)
@@ -221,7 +259,8 @@ async def ask_ollama(prompt: str) -> str:
"options": {"temperature": 0.05, "num_predict": 1024}, "options": {"temperature": 0.05, "num_predict": 1024},
}) })
r.raise_for_status() r.raise_for_status()
return r.json()["message"]["content"] data = r.json()
return data["message"]["content"]
async def scrape_warriorforum() -> list[dict]: async def scrape_warriorforum() -> list[dict]:
results = [] results = []
@@ -245,8 +284,9 @@ async def scrape_warriorforum() -> list[dict]:
txt = date_div.get_text(strip=True) txt = date_div.get_text(strip=True)
m = re.search(r'(\d{10})', txt) m = re.search(r'(\d{10})', txt)
if m: if m:
from datetime import datetime as dtdt date_str = datetime.utcfromtimestamp(int(m.group(1))).strftime('%Y-%m-%d')
date_str = dtdt.utcfromtimestamp(int(m.group(1))).strftime('%Y-%m-%d') if not href.startswith('http'):
href = 'https://www.warriorforum.com' + href
results.append({ results.append({
"title": title, "url": href, "title": title, "url": href,
"author": author, "author": author,
@@ -255,13 +295,12 @@ async def scrape_warriorforum() -> list[dict]:
"date": date_str "date": date_str
}) })
except Exception: except Exception:
traceback.print_exc() logger.error("WarriorForum scrape failed", exc_info=True)
return results return results
async def classify_leads(results: list[dict]) -> list[dict]: async def classify_leads(results: list[dict]) -> list[dict]:
if not results: if not results:
return [] return []
filtered = []
briefs = [r["title"][:200] for r in results] briefs = [r["title"][:200] for r in results]
prompt = f"""Classify each post as LEAD or NOT. prompt = f"""Classify each post as LEAD or NOT.
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them. 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): 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))} {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.""" Return a JSON array like ["yes","no","yes"] matching the order above."""
ai_succeeded = False
try: try:
raw = await ask_ollama(prompt) raw = await ask_ollama(prompt)
raw = raw.strip() raw = raw.strip()
if raw.startswith("```json"): raw = raw[7:] # Strip markdown code fences
if raw.startswith("```"): raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0] 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) answers = json.loads(raw)
if isinstance(answers, list) and len(answers) == len(results): if isinstance(answers, list) and len(answers) == len(results):
ai_succeeded = True
filtered = []
for i, ans in enumerate(answers): for i, ans in enumerate(answers):
if isinstance(ans, str) and ans.lower() == 'yes': if isinstance(ans, str) and ans.lower() == 'yes':
filtered.append(results[i]) filtered.append(results[i])
except: if filtered:
pass return filtered[:10]
if not filtered: # 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: for r in results:
t = r['title'].lower() t = r['title'].lower()
# MUST match a web-dev keyword to be a lead
if not kw_match_strict(t): if not kw_match_strict(t):
continue 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', if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
'affordable web', 'web hosting', 'free website', 'affordable web', 'web hosting', 'free website',
'limited time', 'special offer', 'sign up now', 'limited time', 'special offer', 'sign up now',
'offer']): 'offer']):
continue continue
filtered.append(r) filtered.append(r)
return filtered[:10] return filtered[:10]
return []
@app.get("/health") @app.get("/health")
async def health(): async def health():
@@ -338,15 +395,15 @@ async def scrape_all():
if wf: if wf:
wf = await classify_leads(wf) wf = await classify_leads(wf)
results.extend(wf[:5]) results.extend(wf[:5])
except: except Exception as e:
pass logger.error("WarriorForum scrape in /scrape/all failed: %s", e)
try: try:
fb = await scrape_facebook() fb = await scrape_facebook()
if fb: if fb:
fb = await classify_leads(fb) fb = await classify_leads(fb)
results.extend(fb[:8]) results.extend(fb[:8])
except: except Exception as e:
pass logger.error("Facebook scrape in /scrape/all failed: %s", e)
return results[:12] return results[:12]
if __name__ == "__main__": if __name__ == "__main__":
+46 -41
View File
@@ -22,7 +22,7 @@ $$ LANGUAGE plpgsql;
-- 1. AUTHENTICATION & ROLE MANAGEMENT -- 1. AUTHENTICATION & ROLE MANAGEMENT
-- ============================================================================ -- ============================================================================
CREATE TABLE roles ( CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL, name VARCHAR(50) NOT NULL,
display_name VARCHAR(100), display_name VARCHAR(100),
@@ -35,7 +35,7 @@ CREATE TABLE roles (
CREATE UNIQUE INDEX uq_roles_name ON roles(name); CREATE UNIQUE INDEX uq_roles_name ON roles(name);
CREATE TABLE permissions ( CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
resource VARCHAR(100) NOT NULL, resource VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL, action VARCHAR(50) NOT NULL,
@@ -46,7 +46,7 @@ CREATE TABLE permissions (
CREATE UNIQUE INDEX uq_permissions ON permissions(resource, action); CREATE UNIQUE INDEX uq_permissions ON permissions(resource, action);
CREATE TABLE role_permissions ( CREATE TABLE IF NOT EXISTS role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
granted_by UUID, granted_by UUID,
@@ -54,7 +54,7 @@ CREATE TABLE role_permissions (
PRIMARY KEY (role_id, permission_id) PRIMARY KEY (role_id, permission_id)
); );
CREATE TABLE users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL,
@@ -83,7 +83,7 @@ CREATE INDEX idx_users_is_active ON users(is_active) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_is_locked ON users(is_locked) WHERE is_locked = TRUE AND deleted_at IS NULL; CREATE INDEX idx_users_is_locked ON users(is_locked) WHERE is_locked = TRUE AND deleted_at IS NULL;
CREATE INDEX idx_users_created_by ON users(created_by); CREATE INDEX idx_users_created_by ON users(created_by);
CREATE TABLE user_roles ( CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id), assigned_by UUID REFERENCES users(id),
@@ -98,7 +98,7 @@ CREATE INDEX idx_user_roles_role ON user_roles(role_id);
-- 2. BAN & SUSPENSION SYSTEM -- 2. BAN & SUSPENSION SYSTEM
-- ============================================================================ -- ============================================================================
CREATE TABLE banned_users ( CREATE TABLE IF NOT EXISTS banned_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
banned_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, banned_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
banned_by UUID NOT NULL REFERENCES users(id), banned_by UUID NOT NULL REFERENCES users(id),
@@ -122,7 +122,7 @@ CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
WHERE is_reversed = FALSE; WHERE is_reversed = FALSE;
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by); CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
CREATE TABLE suspended_users ( CREATE TABLE IF NOT EXISTS suspended_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suspended_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, suspended_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
suspended_by UUID NOT NULL REFERENCES users(id), suspended_by UUID NOT NULL REFERENCES users(id),
@@ -145,7 +145,7 @@ CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
-- 3. SESSION & LOGIN MANAGEMENT -- 3. SESSION & LOGIN MANAGEMENT
-- ============================================================================ -- ============================================================================
CREATE TABLE sessions ( CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL, token_hash VARCHAR(255) NOT NULL,
@@ -161,7 +161,7 @@ CREATE INDEX idx_sessions_user ON sessions(user_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX uq_sessions_token ON sessions(token_hash); CREATE UNIQUE INDEX uq_sessions_token ON sessions(token_hash);
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE; CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE;
CREATE TABLE login_attempts ( CREATE TABLE IF NOT EXISTS login_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id), user_id UUID REFERENCES users(id),
username_attempted VARCHAR(100) NOT NULL, username_attempted VARCHAR(100) NOT NULL,
@@ -181,7 +181,7 @@ CREATE INDEX idx_login_attempts_time ON login_attempts(attempted_at DESC);
-- 4. CUSTOMER MANAGEMENT -- 4. CUSTOMER MANAGEMENT
-- ============================================================================ -- ============================================================================
CREATE TABLE customer_statuses ( CREATE TABLE IF NOT EXISTS customer_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
@@ -196,7 +196,7 @@ CREATE TABLE customer_statuses (
CREATE UNIQUE INDEX uq_customer_statuses_name ON customer_statuses(name) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX uq_customer_statuses_name ON customer_statuses(name) WHERE deleted_at IS NULL;
CREATE TABLE customers ( CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_type VARCHAR(20) NOT NULL, customer_type VARCHAR(20) NOT NULL,
status_id UUID NOT NULL REFERENCES customer_statuses(id), status_id UUID NOT NULL REFERENCES customer_statuses(id),
@@ -218,7 +218,7 @@ CREATE INDEX idx_customers_score ON customers(score DESC) WHERE deleted_at IS NU
CREATE INDEX idx_customers_tags ON customers USING GIN(tags) WHERE deleted_at IS NULL; CREATE INDEX idx_customers_tags ON customers USING GIN(tags) WHERE deleted_at IS NULL;
CREATE INDEX idx_customers_created ON customers(created_at DESC) WHERE deleted_at IS NULL; CREATE INDEX idx_customers_created ON customers(created_at DESC) WHERE deleted_at IS NULL;
CREATE TABLE individual_customers ( CREATE TABLE IF NOT EXISTS individual_customers (
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE, customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
first_name VARCHAR(100) NOT NULL, first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL,
@@ -233,7 +233,7 @@ CREATE TABLE individual_customers (
CREATE INDEX idx_individual_names ON individual_customers(last_name, first_name); CREATE INDEX idx_individual_names ON individual_customers(last_name, first_name);
CREATE TABLE company_customers ( CREATE TABLE IF NOT EXISTS company_customers (
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE, customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
company_name VARCHAR(255) NOT NULL, company_name VARCHAR(255) NOT NULL,
registration_number VARCHAR(100), registration_number VARCHAR(100),
@@ -250,7 +250,7 @@ CREATE TABLE company_customers (
CREATE INDEX idx_company_name ON company_customers(company_name); CREATE INDEX idx_company_name ON company_customers(company_name);
CREATE INDEX idx_company_industry ON company_customers(industry); CREATE INDEX idx_company_industry ON company_customers(industry);
CREATE TABLE contact_information ( CREATE TABLE IF NOT EXISTS contact_information (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL, type VARCHAR(20) NOT NULL,
@@ -270,7 +270,7 @@ CREATE INDEX idx_contacts_type ON contact_information(type) WHERE deleted_at IS
CREATE INDEX idx_contacts_value ON contact_information(value) WHERE deleted_at IS NULL; CREATE INDEX idx_contacts_value ON contact_information(value) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_contacts_primary ON contact_information(customer_id, type) WHERE is_primary = TRUE AND deleted_at IS NULL; CREATE UNIQUE INDEX uq_contacts_primary ON contact_information(customer_id, type) WHERE is_primary = TRUE AND deleted_at IS NULL;
CREATE TABLE addresses ( CREATE TABLE IF NOT EXISTS addresses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
address_type VARCHAR(20) NOT NULL, address_type VARCHAR(20) NOT NULL,
@@ -294,7 +294,7 @@ CREATE INDEX idx_addresses_country ON addresses(country) WHERE deleted_at IS NUL
CREATE INDEX idx_addresses_city ON addresses(city) WHERE deleted_at IS NULL; CREATE INDEX idx_addresses_city ON addresses(city) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_addresses_primary ON addresses(customer_id, address_type) WHERE is_primary = TRUE AND deleted_at IS NULL; CREATE UNIQUE INDEX uq_addresses_primary ON addresses(customer_id, address_type) WHERE is_primary = TRUE AND deleted_at IS NULL;
CREATE TABLE customer_notes ( CREATE TABLE IF NOT EXISTS customer_notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id), author_id UUID NOT NULL REFERENCES users(id),
@@ -315,7 +315,7 @@ CREATE INDEX idx_notes_pinned ON customer_notes(is_pinned) WHERE is_pinned = TRU
-- 5. CUSTOMER & INCIDENT REPORTS -- 5. CUSTOMER & INCIDENT REPORTS
-- ============================================================================ -- ============================================================================
CREATE TABLE customer_reports ( CREATE TABLE IF NOT EXISTS customer_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id), customer_id UUID NOT NULL REFERENCES customers(id),
reported_by UUID NOT NULL REFERENCES users(id), reported_by UUID NOT NULL REFERENCES users(id),
@@ -339,7 +339,7 @@ CREATE INDEX idx_customer_reports_reported_by ON customer_reports(reported_by);
CREATE INDEX idx_customer_reports_status ON customer_reports(status); CREATE INDEX idx_customer_reports_status ON customer_reports(status);
CREATE INDEX idx_customer_reports_severity ON customer_reports(severity); CREATE INDEX idx_customer_reports_severity ON customer_reports(severity);
CREATE TABLE incident_reports ( CREATE TABLE IF NOT EXISTS incident_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reported_by UUID NOT NULL REFERENCES users(id), reported_by UUID NOT NULL REFERENCES users(id),
target_user_id UUID REFERENCES users(id), target_user_id UUID REFERENCES users(id),
@@ -368,7 +368,7 @@ CREATE INDEX idx_incident_reports_severity ON incident_reports(severity);
-- 6. LEAD MANAGEMENT -- 6. LEAD MANAGEMENT
-- ============================================================================ -- ============================================================================
CREATE TABLE lead_sources ( CREATE TABLE IF NOT EXISTS lead_sources (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
@@ -380,7 +380,7 @@ CREATE TABLE lead_sources (
CREATE UNIQUE INDEX uq_lead_sources_name ON lead_sources(name) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX uq_lead_sources_name ON lead_sources(name) WHERE deleted_at IS NULL;
CREATE TABLE lead_stages ( CREATE TABLE IF NOT EXISTS lead_stages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
@@ -395,7 +395,7 @@ CREATE TABLE lead_stages (
CREATE UNIQUE INDEX uq_lead_stages_name ON lead_stages(name) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX uq_lead_stages_name ON lead_stages(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_lead_stages_sort ON lead_stages(sort_order); CREATE INDEX idx_lead_stages_sort ON lead_stages(sort_order);
CREATE TABLE leads ( CREATE TABLE IF NOT EXISTS leads (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
source_id UUID REFERENCES lead_sources(id), source_id UUID REFERENCES lead_sources(id),
stage_id UUID NOT NULL REFERENCES lead_stages(id), stage_id UUID NOT NULL REFERENCES lead_stages(id),
@@ -431,7 +431,7 @@ CREATE INDEX idx_leads_converted ON leads(converted_customer_id) WHERE converted
CREATE INDEX idx_leads_email ON leads(email) WHERE deleted_at IS NULL; CREATE INDEX idx_leads_email ON leads(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_leads_active ON leads(stage_id, assigned_to) WHERE deleted_at IS NULL AND converted_customer_id IS NULL; CREATE INDEX idx_leads_active ON leads(stage_id, assigned_to) WHERE deleted_at IS NULL AND converted_customer_id IS NULL;
CREATE TABLE lead_conversions ( CREATE TABLE IF NOT EXISTS lead_conversions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE, lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
@@ -447,7 +447,7 @@ CREATE INDEX idx_lead_conversions_customer ON lead_conversions(customer_id);
-- 7. PRODUCT CATALOG -- 7. PRODUCT CATALOG
-- ============================================================================ -- ============================================================================
CREATE TABLE product_categories ( CREATE TABLE IF NOT EXISTS product_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
@@ -462,7 +462,7 @@ CREATE TABLE product_categories (
CREATE UNIQUE INDEX uq_product_categories_name ON product_categories(name) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX uq_product_categories_name ON product_categories(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_product_categories_parent ON product_categories(parent_id); CREATE INDEX idx_product_categories_parent ON product_categories(parent_id);
CREATE TABLE products ( CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
category_id UUID REFERENCES product_categories(id), category_id UUID REFERENCES product_categories(id),
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -487,7 +487,7 @@ CREATE INDEX idx_products_active ON products(is_active) WHERE deleted_at IS NULL
-- 8. SALES PIPELINE -- 8. SALES PIPELINE
-- ============================================================================ -- ============================================================================
CREATE TABLE deal_stages ( CREATE TABLE IF NOT EXISTS deal_stages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
@@ -502,7 +502,7 @@ CREATE TABLE deal_stages (
CREATE UNIQUE INDEX uq_deal_stages_name ON deal_stages(name) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX uq_deal_stages_name ON deal_stages(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_deal_stages_sort ON deal_stages(sort_order); CREATE INDEX idx_deal_stages_sort ON deal_stages(sort_order);
CREATE TABLE opportunities ( CREATE TABLE IF NOT EXISTS opportunities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id), customer_id UUID NOT NULL REFERENCES customers(id),
lead_id UUID REFERENCES leads(id), lead_id UUID REFERENCES leads(id),
@@ -530,7 +530,7 @@ CREATE INDEX idx_opportunities_won ON opportunities(is_won) WHERE deleted_at IS
CREATE INDEX idx_opportunities_created ON opportunities(created_at DESC) WHERE deleted_at IS NULL; CREATE INDEX idx_opportunities_created ON opportunities(created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_opportunities_owner_stage ON opportunities(owner_id, stage_id) WHERE deleted_at IS NULL AND is_won IS NULL; CREATE INDEX idx_opportunities_owner_stage ON opportunities(owner_id, stage_id) WHERE deleted_at IS NULL AND is_won IS NULL;
CREATE TABLE opportunity_products ( CREATE TABLE IF NOT EXISTS opportunity_products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE, opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id), product_id UUID NOT NULL REFERENCES products(id),
@@ -549,7 +549,7 @@ CREATE INDEX idx_opp_products_product ON opportunity_products(product_id);
-- 9. COMMUNICATION TRACKING -- 9. COMMUNICATION TRACKING
-- ============================================================================ -- ============================================================================
CREATE TABLE communication_types ( CREATE TABLE IF NOT EXISTS communication_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL, name VARCHAR(50) NOT NULL,
description TEXT, description TEXT,
@@ -561,7 +561,7 @@ CREATE TABLE communication_types (
CREATE UNIQUE INDEX uq_comm_types_name ON communication_types(name); CREATE UNIQUE INDEX uq_comm_types_name ON communication_types(name);
CREATE TABLE communications ( CREATE TABLE IF NOT EXISTS communications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id), customer_id UUID NOT NULL REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id), opportunity_id UUID REFERENCES opportunities(id),
@@ -585,7 +585,7 @@ CREATE INDEX idx_communications_type ON communications(type_id) WHERE deleted_at
CREATE INDEX idx_communications_created_by ON communications(created_by) WHERE deleted_at IS NULL; CREATE INDEX idx_communications_created_by ON communications(created_by) WHERE deleted_at IS NULL;
CREATE INDEX idx_communications_started ON communications(started_at) WHERE deleted_at IS NULL; CREATE INDEX idx_communications_started ON communications(started_at) WHERE deleted_at IS NULL;
CREATE TABLE communication_participants ( CREATE TABLE IF NOT EXISTS communication_participants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
communication_id UUID NOT NULL REFERENCES communications(id) ON DELETE CASCADE, communication_id UUID NOT NULL REFERENCES communications(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id), user_id UUID REFERENCES users(id),
@@ -602,7 +602,7 @@ CREATE INDEX idx_comm_participants_user ON communication_participants(user_id);
-- 10. TASKS AND ACTIVITIES -- 10. TASKS AND ACTIVITIES
-- ============================================================================ -- ============================================================================
CREATE TABLE task_priorities ( CREATE TABLE IF NOT EXISTS task_priorities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL, name VARCHAR(50) NOT NULL,
color VARCHAR(7), color VARCHAR(7),
@@ -613,7 +613,7 @@ CREATE TABLE task_priorities (
CREATE UNIQUE INDEX uq_task_priorities_name ON task_priorities(name); CREATE UNIQUE INDEX uq_task_priorities_name ON task_priorities(name);
CREATE TABLE tasks ( CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID REFERENCES customers(id), customer_id UUID REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id), opportunity_id UUID REFERENCES opportunities(id),
@@ -645,7 +645,7 @@ CREATE INDEX idx_tasks_reminder ON tasks(reminder_at) WHERE reminder_sent = FALS
-- 11. INVOICE SUPPORT -- 11. INVOICE SUPPORT
-- ============================================================================ -- ============================================================================
CREATE TABLE invoice_statuses ( CREATE TABLE IF NOT EXISTS invoice_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL, name VARCHAR(50) NOT NULL,
description TEXT, description TEXT,
@@ -657,7 +657,7 @@ CREATE TABLE invoice_statuses (
CREATE UNIQUE INDEX uq_invoice_statuses_name ON invoice_statuses(name); CREATE UNIQUE INDEX uq_invoice_statuses_name ON invoice_statuses(name);
CREATE TABLE invoices ( CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id), customer_id UUID NOT NULL REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id), opportunity_id UUID REFERENCES opportunities(id),
@@ -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; CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
-- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE) -- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE)
CREATE TABLE invoice_items ( CREATE TABLE IF NOT EXISTS invoice_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id), product_id UUID REFERENCES products(id),
@@ -705,7 +705,7 @@ CREATE TABLE invoice_items (
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id); CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
CREATE INDEX idx_invoice_items_product ON invoice_items(product_id); CREATE INDEX idx_invoice_items_product ON invoice_items(product_id);
CREATE TABLE payments ( CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id), invoice_id UUID NOT NULL REFERENCES invoices(id),
amount DECIMAL(15,2) NOT NULL CHECK (amount > 0), amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
@@ -729,7 +729,7 @@ CREATE INDEX idx_payments_transaction ON payments(transaction_id) WHERE transact
-- 12. AUDIT LOGGING -- 12. AUDIT LOGGING
-- ============================================================================ -- ============================================================================
CREATE TABLE audit_logs ( CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
table_name VARCHAR(100) NOT NULL, table_name VARCHAR(100) NOT NULL,
record_id UUID NOT NULL, record_id UUID NOT NULL,
@@ -755,7 +755,7 @@ CREATE INDEX idx_audit_session ON audit_logs(session_id);
-- 13. DUPLICATE DETECTION -- 13. DUPLICATE DETECTION
-- ============================================================================ -- ============================================================================
CREATE TABLE customer_duplicates ( CREATE TABLE IF NOT EXISTS customer_duplicates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
duplicate_customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, duplicate_customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
@@ -810,11 +810,11 @@ BEGIN
) )
LOOP LOOP
EXECUTE format( EXECUTE format(
'CREATE TRIGGER trg_%I_updated_at 'DROP TRIGGER IF EXISTS trg_%I_updated_at ON %I; CREATE TRIGGER trg_%I_updated_at
BEFORE UPDATE ON %I BEFORE UPDATE ON %I
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column()', EXECUTE FUNCTION update_updated_at_column()',
t, t t, t, t, t
); );
END LOOP; END LOOP;
END; END;
@@ -885,6 +885,7 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_enforce_create_user ON users;
CREATE TRIGGER trg_enforce_create_user CREATE TRIGGER trg_enforce_create_user
BEFORE INSERT ON users BEFORE INSERT ON users
FOR EACH ROW FOR EACH ROW
@@ -935,6 +936,7 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_enforce_role_assignment ON user_roles;
CREATE TRIGGER trg_enforce_role_assignment CREATE TRIGGER trg_enforce_role_assignment
BEFORE INSERT ON user_roles BEFORE INSERT ON user_roles
FOR EACH ROW FOR EACH ROW
@@ -973,7 +975,7 @@ BEGIN
audit_action, audit_action,
CASE WHEN audit_action IN ('UPDATE', 'DELETE') THEN old_row ELSE NULL END, CASE WHEN audit_action IN ('UPDATE', 'DELETE') THEN old_row ELSE NULL END,
CASE WHEN audit_action IN ('CREATE', 'UPDATE') THEN new_row ELSE NULL END, CASE WHEN audit_action IN ('CREATE', 'UPDATE') THEN new_row ELSE NULL END,
NULL NULLIF(current_setting('app.current_user_id', true), '')
); );
RETURN COALESCE(NEW, OLD); RETURN COALESCE(NEW, OLD);
@@ -998,6 +1000,7 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_ban ON banned_users;
CREATE TRIGGER trg_audit_ban CREATE TRIGGER trg_audit_ban
AFTER INSERT OR UPDATE ON banned_users AFTER INSERT OR UPDATE ON banned_users
FOR EACH ROW FOR EACH ROW
@@ -1021,6 +1024,7 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_suspension ON suspended_users;
CREATE TRIGGER trg_audit_suspension CREATE TRIGGER trg_audit_suspension
AFTER INSERT OR UPDATE ON suspended_users AFTER INSERT OR UPDATE ON suspended_users
FOR EACH ROW FOR EACH ROW
@@ -1051,6 +1055,7 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
CREATE TRIGGER trg_audit_login CREATE TRIGGER trg_audit_login
AFTER INSERT ON login_attempts AFTER INSERT ON login_attempts
FOR EACH ROW FOR EACH ROW
+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_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_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 -- Seed conversations between superadmin and other users
INSERT INTO conversations (id, created_at) VALUES INSERT INTO conversations (id, created_at) VALUES
('c0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 hours'), ('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 -- 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) ===' \echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
\i 001_schema.sql \i 001_schema.sql
@@ -32,9 +35,4 @@
\i 009_settings.sql \i 009_settings.sql
\echo '=== Migration Complete ===' \echo '=== Migration Complete ==='
\echo '' COMMIT;
\echo 'Test accounts created:'
\echo ' superadmin_demo / SuperAdmin@2026'
\echo ' admin_demo / AdminAccess@2026'
\echo ' sales_demo / SalesAccess@2026'
\echo ' dev_demo / DevTesting@2026'
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: false,
}, },
images: { images: {
remotePatterns: [ remotePatterns: [
+2 -3
View File
@@ -6,12 +6,12 @@
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start", "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:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
"dev:next": "next dev -p 3006", "dev:next": "next dev -p 3006",
"dev:precheck": "powershell -NoProfile -Command \"$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: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:rust": "cd rust-ai && cargo run",
"dev:browser-use": "cd browser-use-service && python main.py", "dev:browser-use": "cd browser-use-service && python main.py",
"build": "next build", "build": "next build",
"start": "npm run dev:next", "start": "next start -p 3006",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
@@ -37,7 +37,6 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jose": "^6.2.3", "jose": "^6.2.3",
"lucide-react": "^0.468.0", "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] [dependencies]
axum = "0.7" 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"] } reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -17,5 +17,5 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.5", features = ["cors"] } tower-http = { version = "0.5", features = ["cors"] }
dotenvy = "0.15" 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::{ use axum::{
extract::State, extract::State,
http::Method, http::{HeaderMap, Method, StatusCode},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use tower_http::cors::{CorsLayer, AllowOrigin, Any};
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use std::collections::HashMap;
use std::fs; use std::fs;
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer}; use tokio::sync::Mutex;
use tracing::{error, info}; use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
use rand::Rng; use rand::Rng;
use std::time::Duration; use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH}; 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 ─────────────────────────────────────────────── // ── Shared state ───────────────────────────────────────────────
struct AppState { struct AppState {
@@ -23,6 +72,9 @@ struct AppState {
model: String, model: String,
jobs: Vec<Job>, jobs: Vec<Job>,
leads: Arc<Mutex<LeadStore>>, leads: Arc<Mutex<LeadStore>>,
http_client: reqwest::Client,
jwt_secret: String,
rate_limiter: RateLimiter,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -47,7 +99,6 @@ impl LeadStore {
} }
fn push(&mut self, lead: Lead) { fn push(&mut self, lead: Lead) {
// Deduplicate by URL
if !self.leads.iter().any(|l| l.url == lead.url) { if !self.leads.iter().any(|l| l.url == lead.url) {
self.leads.insert(0, lead); self.leads.insert(0, lead);
self.leads.truncate(self.max_size); self.leads.truncate(self.max_size);
@@ -55,9 +106,9 @@ impl LeadStore {
} }
fn recent(&self, max_age_secs: u64, limit: usize) -> Vec<Lead> { 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() 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) .take(limit)
.cloned() .cloned()
.collect() .collect()
@@ -75,8 +126,6 @@ struct Job {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ChatRequest { struct ChatRequest {
message: String, message: String,
user_id: String,
user_role: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -127,7 +176,23 @@ struct OllamaResponseMessage {
content: String, 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 { fn format_leads_output(leads: &[Lead]) -> String {
if leads.is_empty() { if leads.is_empty() {
@@ -138,7 +203,7 @@ fn format_leads_output(leads: &[Lead]) -> String {
.enumerate() .enumerate()
.map(|(i, l)| { .map(|(i, l)| {
let author = if l.author.is_empty() { "Unknown" } else { &l.author }; 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!( format!(
"{}. {}\n {}\n {}\n {}", "{}. {}\n {}\n {}\n {}",
i + 1, i + 1,
@@ -161,9 +226,7 @@ fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
let lead_summary: Vec<String> = leads let lead_summary: Vec<String> = leads
.iter() .iter()
.map(|l| { .map(|l| format!("{} | {} | {}", l.author, l.title, l.url))
format!("{} | {} | {}", l.author, l.title, l.url)
})
.collect(); .collect();
let lead_summary_str = if lead_summary.is_empty() { let lead_summary_str = if lead_summary.is_empty() {
"None yet.".to_string() "None yet.".to_string()
@@ -187,62 +250,56 @@ fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
async fn handle_chat( async fn handle_chat(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ChatRequest>, Json(req): Json<ChatRequest>,
) -> Result<Json<ChatResponse>, (axum::http::StatusCode, String)> { ) -> Result<Json<ChatResponse>, (StatusCode, String)> {
// Validate role let claims = extract_claims(&headers, &state)?;
match req.user_role.as_str() {
"sales" | "admin" | "super_admin" => {} if !state.rate_limiter.check(&claims.user_id).await {
_ => return Err((axum::http::StatusCode::FORBIDDEN, "Forbidden".to_string())), 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_lower = req.message.to_lowercase();
let msg_words: Vec<&str> = msg_lower.split_whitespace().collect(); 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_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_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"); let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") { if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
// Scrape fresh leads now — call both services let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); let service_url = "http://localhost:3008/scrape/all".to_string();
let client = reqwest::Client::new(); if let Ok(resp) = state.http_client
let services = [ .post(&service_url)
"http://localhost:3008/scrape/all".to_string(), .timeout(Duration::from_secs(120))
]; .send()
for service_url in &services { .await
if let Ok(resp) = client {
.post(service_url) if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await {
.timeout(Duration::from_secs(120)) info!("Scraped {} leads from {}", leads_data.len(), service_url);
.send() let mut store = state.leads.lock().await;
.await for item in &leads_data {
{ store.push(Lead {
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await { title: truncate(item["title"].as_str().unwrap_or(""), 120),
info!("Scraped {} leads from {}", leads_data.len(), service_url); url: item["url"].as_str().unwrap_or("").to_string(),
let mut store = state.leads.lock().unwrap(); source: item["source"].as_str().unwrap_or("unknown").to_string(),
for item in &leads_data { found_at: now,
store.push(Lead { author: truncate(item["author"].as_str().unwrap_or(""), 60),
title: item["title"].as_str().unwrap_or("").chars().take(120).collect(), date: truncate(item["date"].as_str().unwrap_or(""), 30),
url: item["url"].as_str().unwrap_or("").to_string(), content: truncate(item["content"].as_str().unwrap_or(""), 300),
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(),
});
}
} }
} 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 response = format_leads_output(&recent_leads);
let _ = sqlx::query( let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)", "INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
) )
.bind(Uuid::new_v4()) .bind(Uuid::new_v4())
.bind(Uuid::parse_str(&req.user_id).unwrap_or(Uuid::nil())) .bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&req.user_role) .bind(&claims.role)
.bind(&req.message) .bind(&req.message)
.bind(&response) .bind(&response)
.execute(&state.db) .execute(&state.db)
@@ -250,84 +307,62 @@ async fn handle_chat(
return Ok(Json(ChatResponse { response })); 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 system_prompt = build_system_prompt(&state.jobs, &recent_leads);
let message_text = req.message.clone(); let message_text = req.message.clone();
let ollama_req = OllamaRequest { let ollama_req = OllamaRequest {
model: state.model.clone(), model: state.model.clone(),
messages: vec![ messages: vec![
OllamaChatMessage { OllamaChatMessage { role: "system".to_string(), content: system_prompt },
role: "system".to_string(), OllamaChatMessage { role: "user".to_string(), content: req.message.clone() },
content: system_prompt,
},
OllamaChatMessage {
role: "user".to_string(),
content: req.message.clone(),
},
], ],
stream: false, stream: false,
options: OllamaOptions { options: OllamaOptions { temperature: 0.7, num_predict: 1024 },
temperature: 0.7,
num_predict: 1024,
},
}; };
let client = reqwest::Client::new(); let resp = state.http_client
let resp = client
.post(format!("{}/api/chat", state.ollama_url)) .post(format!("{}/api/chat", state.ollama_url))
.json(&ollama_req) .json(&ollama_req)
.send() .send()
.await .await
.map_err(|e| { .map_err(|e| {
error!("Ollama request failed: {}", e); error!("Ollama request failed: {}", e);
( (StatusCode::SERVICE_UNAVAILABLE, "AI service unavailable".to_string())
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"AI service unavailable".to_string(),
)
})?; })?;
let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| { let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| {
error!("Failed to parse Ollama response: {}", e); error!("Failed to parse Ollama response: {}", e);
( (StatusCode::SERVICE_UNAVAILABLE, "AI response parse error".to_string())
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"AI response parse error".to_string(),
)
})?; })?;
let response_text = ollama_resp let response_text = ollama_resp.message.map(|m| m.content).unwrap_or_default();
.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( let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)", "INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
) )
.bind(Uuid::new_v4()) .bind(Uuid::new_v4())
.bind(user_id) .bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&req.user_role) .bind(&claims.role)
.bind(&message_text) .bind(&message_text)
.bind(&response_text) .bind(&response_text)
.execute(&state.db) .execute(&state.db)
.await; .await;
Ok(Json(ChatResponse { Ok(Json(ChatResponse { response: response_text }))
response: response_text,
}))
} }
// ── Jobs handler ─────────────────────────────────────────────── // ── Jobs handler ───────────────────────────────────────────────
async fn handle_jobs( async fn handle_jobs(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Json<JobsResponse> { headers: HeaderMap,
Json(JobsResponse { ) -> Result<Json<JobsResponse>, (StatusCode, String)> {
jobs: state.jobs.clone(), 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 ───────────────────────────────────────────── // ── Health handler ─────────────────────────────────────────────
@@ -354,42 +389,32 @@ async fn main() {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let database_url = let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
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 = let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
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 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 host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("AI_PORT") let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
.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_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_content = fs::read_to_string(&jobs_path).unwrap_or_default();
let jobs: Vec<Job> = jobs_content let jobs: Vec<Job> = jobs_content.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect();
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
info!( info!("Loaded {} job categories, model: {}, Ollama: {}", jobs.len(), model, ollama_url);
"Loaded {} job categories, model: {}, Ollama: {}",
jobs.len(),
model,
ollama_url
);
// Connect to database
let db = PgPoolOptions::new() let db = PgPoolOptions::new()
.max_connections(5) .max_connections(20)
.connect(&database_url) .connect(&database_url)
.await .await
.expect("Failed to connect to database"); .expect("Failed to connect to database");
info!("Connected to PostgreSQL"); 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 lead_store = Arc::new(Mutex::new(LeadStore::new(100)));
let state = Arc::new(AppState { let state = Arc::new(AppState {
@@ -398,11 +423,16 @@ async fn main() {
model, model,
jobs, jobs,
leads: lead_store.clone(), leads: lead_store.clone(),
http_client,
jwt_secret,
rate_limiter: RateLimiter::new(30, 60),
}); });
// CORS layer
let cors = CorsLayer::new() 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_methods([Method::GET, Method::POST])
.allow_headers(Any); .allow_headers(Any);
@@ -420,43 +450,57 @@ async fn main() {
.await .await
.expect("Failed to bind address"); .expect("Failed to bind address");
// Start background scrapers
let bg_leads = lead_store.clone(); let bg_leads = lead_store.clone();
tokio::task::spawn_blocking(move || { let bg_url = "http://localhost:3008/scrape/all".to_string();
let bg_client = reqwest::blocking::Client::builder() tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(120)) .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 { loop {
if let Some(ref c) = bg_client { let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let urls = vec![ match client.post(&bg_url).send().await {
"http://localhost:3008/scrape/all".to_string(), Ok(resp) => {
]; if resp.status().is_success() {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); match resp.json::<Vec<serde_json::Value>>().await {
for url in &urls { Ok(data) => {
if let Ok(resp) = c.post(url).send() { let mut store = bg_leads.lock().await;
if let Ok(data) = resp.json::<Vec<serde_json::Value>>() { for item in &data {
let mut store = bg_leads.lock().unwrap(); store.push(Lead {
for item in &data { title: truncate(item["title"].as_str().unwrap_or(""), 120),
store.push(Lead { url: item["url"].as_str().unwrap_or("").to_string(),
title: item["title"].as_str().unwrap_or("").chars().take(120).collect(), source: item["source"].as_str().unwrap_or("unknown").to_string(),
url: item["url"].as_str().unwrap_or("").to_string(), found_at: now,
source: item["source"].as_str().unwrap_or("unknown").to_string(), author: truncate(item["author"].as_str().unwrap_or(""), 60),
found_at: now, date: truncate(item["date"].as_str().unwrap_or(""), 30),
author: item["author"].as_str().unwrap_or("").chars().take(60).collect(), content: truncate(item["content"].as_str().unwrap_or(""), 300),
date: item["date"].as_str().unwrap_or("").chars().take(30).collect(), });
content: item["content"].as_str().unwrap_or("").chars().take(300).collect(), }
}); }
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); 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) axum::serve(listener, app)
.await .await
.expect("Server failed"); .expect("Server failed");
} }
+73 -11
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useRef, useCallback, useEffect } from "react" import { useState, useRef, useCallback, useEffect, useMemo } from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" 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() { export default function ChatsPage() {
const { theme } = useTheme() const { theme } = useTheme()
const { user } = useUser() const { user } = useUser()
@@ -129,6 +132,8 @@ export default function ChatsPage() {
const mediaRecorderRef = useRef<MediaRecorder | null>(null) const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const recordingChunksRef = useRef<Blob[]>([]) const recordingChunksRef = useRef<Blob[]>([])
const resizeStartRef = useRef({ x: 0, width: 0 }) 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()) const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
@@ -141,12 +146,12 @@ export default function ChatsPage() {
if (!user) return null if (!user) return null
const messages = conversationMessages.get(activeChat || "") || [] const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = conversations.find((c) => c.id === activeChat) const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const otherParticipant = (conv: any) => conv.otherUser const filteredConversations = useMemo(() => {
const filteredConversations = searchQuery.trim() if (!searchQuery.trim()) return conversations
? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase())) return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
: conversations }, [conversations, searchQuery])
// Fetch conversations from API // Fetch conversations from API
useEffect(() => { useEffect(() => {
@@ -161,7 +166,7 @@ export default function ChatsPage() {
if (convs.length > 0) setActiveChat(convs[0].id) if (convs.length > 0) setActiveChat(convs[0].id)
} }
} catch { } catch {
// ignore console.warn("Failed to fetch conversations in chats page")
} finally { } finally {
setLoadingChats(false) setLoadingChats(false)
} }
@@ -172,6 +177,10 @@ export default function ChatsPage() {
// Fetch messages when active chat changes, and mark as read // Fetch messages when active chat changes, and mark as read
useEffect(() => { useEffect(() => {
if (!activeChat) return if (!activeChat) return
conversationOrderRef.current = [
activeChat,
...conversationOrderRef.current.filter((id) => id !== activeChat),
]
const fetchMsgs = async () => { const fetchMsgs = async () => {
try { try {
const res = await fetch(`/api/conversations/${activeChat}/messages`) const res = await fetch(`/api/conversations/${activeChat}/messages`)
@@ -180,6 +189,30 @@ export default function ChatsPage() {
setConversationMessages((prev) => { setConversationMessages((prev) => {
const next = new Map(prev) const next = new Map(prev)
next.set(activeChat, data.messages || []) 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 return next
}) })
} }
@@ -206,7 +239,7 @@ export default function ChatsPage() {
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id)) const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.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) setSearchingUsers(false)
}, 300) }, 300)
return () => clearTimeout(timer) return () => clearTimeout(timer)
@@ -222,6 +255,14 @@ export default function ChatsPage() {
return () => document.removeEventListener("mousedown", handleClickOutside) 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) => { const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
setIsResizing(true) setIsResizing(true)
@@ -254,8 +295,16 @@ export default function ChatsPage() {
setTimeout(() => autoResizeTextarea(), 0) setTimeout(() => autoResizeTextarea(), 0)
} }
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []) 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]) setAttachments((prev) => [...prev, ...files])
if (e.target) e.target.value = "" if (e.target) e.target.value = ""
} }
@@ -319,6 +368,7 @@ export default function ChatsPage() {
}) })
} }
} catch { } catch {
console.warn("Failed to send message in chats page")
toast.error("Failed to send message") toast.error("Failed to send message")
} }
} }
@@ -341,7 +391,11 @@ export default function ChatsPage() {
return return
} }
const fileAttachments = attachments.length > 0 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 : undefined
if (replyingTo) { if (replyingTo) {
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
@@ -356,6 +410,10 @@ export default function ChatsPage() {
} }
const handleDeleteMessage = (messageId: string) => { 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) => { setConversationMessages((prev) => {
const next = new Map(prev) const next = new Map(prev)
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId) const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
@@ -383,6 +441,7 @@ export default function ChatsPage() {
recorder.onstop = () => { recorder.onstop = () => {
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" }) const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
blobUrlsRef.current.push(url)
addMessageToChat("", { url, duration: recordingDurationRef.current }) addMessageToChat("", { url, duration: recordingDurationRef.current })
stream.getTracks().forEach((t) => t.stop()) stream.getTracks().forEach((t) => t.stop())
setRecordingDuration(0) setRecordingDuration(0)
@@ -392,6 +451,7 @@ export default function ChatsPage() {
setIsRecording(true) setIsRecording(true)
setIsPaused(false) setIsPaused(false)
} catch { } catch {
console.warn("Failed to start recording in chats page")
toast.error("Microphone access denied") toast.error("Microphone access denied")
} }
} }
@@ -425,6 +485,7 @@ export default function ChatsPage() {
setIsPaused(false) setIsPaused(false)
setRecordingDuration(0) setRecordingDuration(0)
recordingDurationRef.current = 0 recordingDurationRef.current = 0
recordingChunksRef.current = []
} }
useEffect(() => { useEffect(() => {
@@ -532,7 +593,7 @@ export default function ChatsPage() {
setSearchQuery("") 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" 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") toast.success("Message forwarded")
} }
} catch { } catch {
console.warn("Failed to forward message in chats page")
toast.error("Failed to forward message") toast.error("Failed to forward message")
} }
}} }}
+1 -1
View File
@@ -38,7 +38,7 @@ export default function DashboardPage() {
setStats(data) setStats(data)
} }
} catch { } 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 { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider" import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider" import { NotificationProvider } from "@/providers/notification-provider"
import { ErrorBoundary } from "@/components/shared/error-boundary"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
@@ -29,7 +30,9 @@ export default function DashboardLayout({
return ( return (
<UserProvider> <UserProvider>
<NotificationProvider> <NotificationProvider>
<DashboardContent>{children}</DashboardContent> <ErrorBoundary>
<DashboardContent>{children}</DashboardContent>
</ErrorBoundary>
</NotificationProvider> </NotificationProvider>
</UserProvider> </UserProvider>
) )
+23 -8
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useCallback } from "react"
import Link from "next/link" import Link from "next/link"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { use } from "react" import { use } from "react"
@@ -26,22 +26,26 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
const [leadNotes, setLeadNotes] = useState<Note[]>([]) const [leadNotes, setLeadNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true) 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(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
const [leadRes, notesRes] = await Promise.all([ const [leadRes] = await Promise.all([
fetch(`/api/leads/${id}`), fetch(`/api/leads/${id}`),
fetch(`/api/leads/${id}/notes`), fetchNotes(),
]) ])
if (leadRes.ok) setLead(await leadRes.json()) if (leadRes.ok) setLead(await leadRes.json())
if (notesRes.ok) setLeadNotes(await notesRes.json())
} catch { } catch {
// ignore console.warn("Failed to fetch lead details")
} }
setLoading(false) setLoading(false)
} }
fetchData() fetchData()
}, [id]) }, [id, fetchNotes])
if (loading) { if (loading) {
return ( return (
@@ -89,8 +93,19 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
> >
<Select <Select
value={lead.status} value={lead.status}
onValueChange={(v) => { onValueChange={async (v) => {
const previousStatus = lead.status
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev) 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]"> <SelectTrigger className="h-9 w-[140px]">
@@ -125,7 +140,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }} transition={{ duration: 0.3, delay: 0.1 }}
> >
<NoteForm leadId={lead.id} /> <NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
</motion.div> </motion.div>
<motion.div <motion.div
+29 -31
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
@@ -26,9 +26,7 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { ArrowLeft } from "lucide-react" import { ArrowLeft } from "lucide-react"
import { Lead, LeadStatus } from "@/types" import { User } from "@/types"
import { leads } from "@/data/leads"
import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider" import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants" import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
@@ -49,6 +47,14 @@ export default function CreateLeadPage() {
const router = useRouter() const router = useRouter()
const { addNotification } = useNotifications() const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false) 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>({ const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema), resolver: zodResolver(leadFormSchema),
@@ -64,35 +70,27 @@ export default function CreateLeadPage() {
}, },
}) })
function onSubmit(values: LeadFormValues) { async function onSubmit(values: LeadFormValues) {
setSaving(true) setSaving(true)
const now = new Date().toISOString() try {
const assignedUserId: string | null = values.assignedUserId === "none" ? null : (values.assignedUserId ?? null) const payload = {
const assignedUser = assignedUserId ? users.find((u) => u.id === assignedUserId) ?? null : null ...values,
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
const newLead: Lead = { }
id: `lead-${String(leads.length + 1).padStart(3, "0")}`, const res = await fetch("/api/leads", {
companyName: values.companyName, method: "POST",
contactName: values.contactName, headers: { "Content-Type": "application/json" },
email: values.email, body: JSON.stringify(payload),
phone: values.phone ?? "", })
source: values.source ?? "", if (!res.ok) throw new Error("Failed to create lead")
description: values.description ?? "", const data = await res.json()
status: values.status as LeadStatus, addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
assignedUserId, router.push("/leads")
assignedUser, } catch (e) {
createdAt: now, console.error("Create lead error:", e)
updatedAt: now, } finally {
setSaving(false)
} }
leads.unshift(newLead)
addNotification(
"lead_created",
"New Lead Created",
`${newLead.companyName}${newLead.contactName}`,
`/leads/${newLead.id}`
)
router.push("/leads")
} }
return ( return (
+1
View File
@@ -39,6 +39,7 @@ export default function ProfilePage() {
toast.error("Failed to save avatar") toast.error("Failed to save avatar")
} }
} catch { } catch {
console.warn("Failed to save avatar")
toast.error("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 || []) setUsers(data.users || [])
} }
} catch { } catch {
// ignore console.warn("Failed to fetch users in users page")
} finally { } finally {
setLoading(false) 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 }) 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 }) return NextResponse.json({ response })
} catch (error) { } catch (error) {
+1
View File
@@ -14,6 +14,7 @@ export async function GET() {
const jobs = await fetchJobs() const jobs = await fetchJobs()
return NextResponse.json({ jobs }) return NextResponse.json({ jobs })
} catch { } catch {
console.warn("Failed to fetch AI jobs in API route")
return NextResponse.json({ jobs: [] }) return NextResponse.json({ jobs: [] })
} }
} }
+1 -1
View File
@@ -113,7 +113,7 @@ export async function POST(request: NextRequest) {
} catch (error) { } catch (error) {
console.error("Login error:", error) console.error("Login error:", error)
return NextResponse.json( return NextResponse.json(
{ error: "Authentication service unavailable. Please ensure PostgreSQL is running and configured." }, { error: "Authentication service unavailable." },
{ status: 503 } { status: 503 }
) )
} }
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
@@ -12,18 +13,40 @@ export async function GET(
const { id } = await params 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([ const [msgResult, otherReadResult] = await Promise.all([
query( query(msgSql, msgParams),
`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( query(
`SELECT last_read_at FROM conversation_participants `SELECT last_read_at FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`, WHERE conversation_id = $1 AND user_id != $2`,
@@ -40,7 +63,7 @@ export async function GET(
conversationId: id, conversationId: id,
senderId: row.sender_id, senderId: row.sender_id,
senderName: row.sender_name, 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, content: row.content,
timestamp: formatTime(new Date(row.created_at)), timestamp: formatTime(new Date(row.created_at)),
createdAt: row.created_at, createdAt: row.created_at,
@@ -65,8 +88,17 @@ export async function POST(
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params const { id } = await params
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()) { if (!content?.trim()) {
return NextResponse.json({ error: "Message content is required" }, { status: 400 }) 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 { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() { export async function GET() {
try { try {
@@ -26,7 +27,8 @@ export async function GET() {
WHERE c.id IN ( WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1 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], [user.id],
) )
@@ -37,7 +39,7 @@ export async function GET() {
id: row.other_user_id, id: row.other_user_id,
name: row.other_user_name, name: row.other_user_name,
email: row.other_user_email, 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 || "", lastMessage: row.last_message || "",
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "", 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, id: other.id,
name: other.name, name: other.name,
email: other.email, 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: "", lastMessage: "",
lastMessageTime: "", lastMessageTime: "",
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function getPeriodDateRange(period: string): { start: Date; end: Date } { function getPeriodDateRange(period: string): { start: Date; end: Date } {
const end = new Date() const end = new Date()
@@ -158,7 +159,7 @@ export async function GET(request: NextRequest) {
id: r.user_id, id: r.user_id,
name: `${r.first_name} ${r.last_name}`, name: `${r.first_name} ${r.last_name}`,
email: r.user_email, 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, } : null,
createdAt: r.created_at, createdAt: r.created_at,
updatedAt: r.updated_at, updatedAt: r.updated_at,
+8 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
@@ -32,6 +33,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params const { id } = await params
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( const result = await query(
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content, `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 FROM customer_notes cn
JOIN users u ON u.id = cn.author_id JOIN users u ON u.id = cn.author_id
WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL
ORDER BY cn.created_at DESC`, ORDER BY cn.created_at DESC
[id] LIMIT $2 OFFSET $3`,
[id, limit, offset]
) )
const notes = result.rows.map((r: any) => ({ const notes = result.rows.map((r: any) => ({
@@ -48,7 +53,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
leadId: id, leadId: id,
userId: r.user_id, userId: r.user_id,
authorName: `${r.first_name} ${r.last_name}`, 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, note: r.content,
createdAt: r.created_at, createdAt: r.created_at,
updatedAt: r.updated_at, updatedAt: r.updated_at,
+88 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function stageToStatus(name: string): string { function stageToStatus(name: string): string {
switch (name) { switch (name) {
@@ -22,6 +23,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
const result = await query( const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, `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 FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.id = $1 AND l.deleted_at IS NULL`, WHERE l.id = $1 AND l.deleted_at IS NULL
[id] AND ($2 = true OR l.assigned_to = $3)`,
[id, isAdmin, user.id]
) )
if (result.rows.length === 0) { if (result.rows.length === 0) {
@@ -54,7 +57,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: r.user_id, id: r.user_id,
name: `${r.first_name} ${r.last_name}`, name: `${r.first_name} ${r.last_name}`,
email: r.user_email, 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, } : null,
createdAt: r.created_at, createdAt: r.created_at,
updatedAt: r.updated_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 }) 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 { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function stageToStatus(name: string): string { function stageToStatus(name: string): string {
switch (name) { switch (name) {
@@ -38,6 +39,9 @@ export async function GET(request: NextRequest) {
const search = searchParams.get("search") || "" const search = searchParams.get("search") || ""
const status = searchParams.get("status") || "all" const status = searchParams.get("status") || "all"
const period = searchParams.get("period") || "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, 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, 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++ paramIdx++
} }
if (!isAdmin) {
sql += ` AND l.assigned_to = $${paramIdx}`
params.push(user.id)
paramIdx++
}
sql += ` ORDER BY l.created_at DESC` 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) const result = await query(sql, params)
@@ -86,7 +99,7 @@ export async function GET(request: NextRequest) {
id: r.user_id, id: r.user_id,
name: `${r.first_name} ${r.last_name}`, name: `${r.first_name} ${r.last_name}`,
email: r.user_email, 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, } : null,
createdAt: r.created_at, createdAt: r.created_at,
updatedAt: r.updated_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 }) 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 { try {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
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 result = await query(`SELECT * FROM company_settings ORDER BY updated_at DESC LIMIT 1`)
const row = result.rows[0] const row = result.rows[0]
@@ -37,6 +40,9 @@ export async function PATCH(request: NextRequest) {
try { try {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const body = await request.json() const body = await request.json()
+4
View File
@@ -1,10 +1,14 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import os from "os" import os from "os"
import { getSessionUser } from "@/lib/auth"
let prevCpu = process.cpuUsage() let prevCpu = process.cpuUsage()
let prevTime = Date.now() let prevTime = Date.now()
export async function GET() { export async function GET() {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const now = Date.now() const now = Date.now()
const elapsed = now - prevTime const elapsed = now - prevTime
const currentCpu = process.cpuUsage() const currentCpu = process.cpuUsage()
+7
View File
@@ -8,9 +8,16 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
if (!sessionUser) { if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) 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 const { id } = await params
if (id === sessionUser.id) {
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
}
await query( await query(
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, `UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
[id] [id]
+22 -4
View File
@@ -1,9 +1,17 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { hashPassword, getSessionUser } from "@/lib/auth" import { hashPassword, getSessionUser } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() { export async function GET(request: NextRequest) {
try { 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( const result = await query(
`SELECT u.id, u.username, u.email, u.first_name, u.last_name, `SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active AS active, u.created_at, u.avatar_url, 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 user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id JOIN roles r ON r.id = ur.role_id
WHERE u.deleted_at IS NULL 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) => ({ const users = result.rows.map((row: any) => ({
id: row.id, id: row.id,
@@ -20,7 +30,7 @@ export async function GET() {
email: row.email, email: row.email,
role: row.role.toLowerCase(), role: row.role.toLowerCase(),
active: row.active, 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, createdAt: row.created_at,
})) }))
return NextResponse.json({ users }, { status: 200 }) return NextResponse.json({ users }, { status: 200 })
@@ -36,12 +46,20 @@ export async function POST(request: NextRequest) {
if (!sessionUser) { if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) 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() const { name, email, password, role, active } = await request.json()
if (!name || !email || !password || !role) { if (!name || !email || !password || !role) {
return NextResponse.json({ error: "Name, email, password, and role are required" }, { status: 400 }) 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 nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0] const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName 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) `INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`, 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 = ( const roleId = (
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -29,7 +30,7 @@ export async function GET(request: NextRequest) {
id: row.id, id: row.id,
name: row.name, name: row.name,
email: row.email, 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 }) 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 }}> <label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
Password Password
</label> </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? Forgot password?
</button> </button>
</div> </div>
+3 -35
View File
@@ -32,11 +32,11 @@ export function AIChat() {
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
if (data.jobs?.length) { 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([ setMessages([
{ {
role: "assistant", 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 { } 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(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
@@ -37,7 +37,7 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
setChartData(data.leadsPerMonth || []) setChartData(data.leadsPerMonth || [])
} }
} catch { } catch {
// ignore console.warn("Failed to fetch year data in leads per month chart")
} }
} }
const thisYear = new Date().getFullYear() const thisYear = new Date().getFullYear()
+1 -1
View File
@@ -18,7 +18,7 @@ export function SystemMonitor() {
setRssMB(data.rssMB) setRssMB(data.rssMB)
setCpuPct(data.cpuPct) setCpuPct(data.cpuPct)
} catch { } catch {
// ignore console.warn("Failed to fetch system stats")
} }
} }
+42 -18
View File
@@ -32,7 +32,6 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { Lead, LeadStatus, User } from "@/types" import { Lead, LeadStatus, User } from "@/types"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants" import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider" import { useNotifications } from "@/providers/notification-provider"
const leadFormSchema = z.object({ const leadFormSchema = z.object({
@@ -52,11 +51,21 @@ interface LeadFormDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
lead?: Lead | null lead?: Lead | null
onSuccess?: () => void
} }
export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps) { export function LeadFormDialog({ open, onOpenChange, lead, onSuccess }: LeadFormDialogProps) {
const isEditing = !!lead const isEditing = !!lead
const { addNotification } = useNotifications() 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>({ const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema), resolver: zodResolver(leadFormSchema),
@@ -98,22 +107,37 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
} }
}, [lead, form]) }, [lead, form])
function onSubmit(values: LeadFormValues) { async function onSubmit(values: LeadFormValues) {
const payload = { setSaving(true)
...values, try {
assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId, 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 ( return (
@@ -277,7 +301,7 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit">{isEditing ? "Save Changes" : "Create Lead"}</Button> <Button type="submit" disabled={saving}>{saving ? "Saving..." : isEditing ? "Save Changes" : "Create Lead"}</Button>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>
+38 -25
View File
@@ -6,12 +6,15 @@ import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Send } from "lucide-react" import { Send } from "lucide-react"
import { useUser } from "@/providers/user-provider"
interface NoteFormProps { interface NoteFormProps {
leadId: string leadId: string
onNoteAdded?: () => void
} }
export function NoteForm({ leadId }: NoteFormProps) { export function NoteForm({ leadId, onNoteAdded }: NoteFormProps) {
const { user } = useUser()
const [note, setNote] = useState("") const [note, setNote] = useState("")
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
@@ -25,43 +28,53 @@ export function NoteForm({ leadId }: NoteFormProps) {
body: JSON.stringify({ content: note }), body: JSON.stringify({ content: note }),
}) })
setNote("") setNote("")
window.location.reload() onNoteAdded?.()
} catch { } catch {
// ignore console.warn("Failed to add note")
} }
setSubmitting(false) setSubmitting(false)
} }
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Add Note</CardTitle> <CardTitle>Add Note</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex gap-4"> <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<Avatar className="h-10 w-10 shrink-0"> <div className="flex gap-4">
<AvatarImage src="https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=64" /> <Avatar className="h-10 w-10 shrink-0">
<AvatarFallback>SC</AvatarFallback> <AvatarImage src={user?.avatar || undefined} />
</Avatar> <AvatarFallback>{user?.name?.charAt(0) || "U"}</AvatarFallback>
<div className="flex-1 space-y-3"> </Avatar>
<Textarea <div className="flex-1 space-y-3">
placeholder="Write a note about this lead..." <Textarea
value={note} placeholder="Write a note about this lead..."
onChange={(e) => setNote(e.target.value)} value={note}
className="min-h-[100px] resize-none" onChange={(e) => setNote(e.target.value)}
/> onKeyDown={handleKeyDown}
<div className="flex justify-end"> className="min-h-[100px] resize-none"
<Button />
onClick={handleSubmit} <div className="flex justify-end">
disabled={!note.trim() || submitting} <Button
className="gap-2" type="submit"
> disabled={!note.trim() || submitting}
<Send className="h-4 w-4" /> className="gap-2"
{submitting ? "Adding..." : "Add Note"} >
</Button> <Send className="h-4 w-4" />
{submitting ? "Adding..." : "Add Note"}
</Button>
</div>
</div> </div>
</div> </div>
</div> </form>
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -35,7 +35,7 @@ export function CompanySettingsForm() {
setData(json) setData(json)
} }
} catch { } catch {
// ignore console.warn("Failed to load company settings")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -57,6 +57,7 @@ export function CompanySettingsForm() {
toast.error("Failed to save company settings") toast.error("Failed to save company settings")
} }
} catch { } catch {
console.warn("Failed to save company settings")
toast.error("Failed to save company settings") toast.error("Failed to save company settings")
} finally { } finally {
setSaving(false) 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)} /> <Input id="company-address" disabled={loading} value={data.companyAddress} onChange={(e) => update("companyAddress", e.target.value)} />
</div> </div>
</div> </div>
<div className="flex justify-end pt-4"> <form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<Button onClick={handleSave} disabled={loading || saving}> <div className="flex justify-end pt-4">
{saving ? "Saving..." : "Save Changes"} <Button type="submit" disabled={loading || saving}>
</Button> {saving ? "Saving..." : "Save Changes"}
</div> </Button>
</div>
</form>
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -45,7 +45,7 @@ export function NotificationSettings() {
setPrefs(data) setPrefs(data)
} }
} catch { } catch {
// ignore console.warn("Failed to load notification preferences")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -67,6 +67,7 @@ export function NotificationSettings() {
toast.error("Failed to save preferences") toast.error("Failed to save preferences")
} }
} catch { } catch {
console.warn("Failed to save notification preferences")
toast.error("Failed to save preferences") toast.error("Failed to save preferences")
} finally { } finally {
setSaving(false) setSaving(false)
@@ -105,11 +106,13 @@ export function NotificationSettings() {
/> />
</div> </div>
))} ))}
<div className="flex justify-end pt-4"> <form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<Button onClick={handleSave} disabled={loading || saving}> <div className="flex justify-end pt-4">
{saving ? "Saving..." : "Save Preferences"} <Button type="submit" disabled={loading || saving}>
</Button> {saving ? "Saving..." : "Save Preferences"}
</div> </Button>
</div>
</form>
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -37,7 +37,7 @@ export function UserPreferencesForm() {
setPrefs(json) setPrefs(json)
} }
} catch { } catch {
// ignore console.warn("Failed to load user preferences")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -59,6 +59,7 @@ export function UserPreferencesForm() {
toast.error("Failed to save preferences") toast.error("Failed to save preferences")
} }
} catch { } catch {
console.warn("Failed to save user preferences")
toast.error("Failed to save preferences") toast.error("Failed to save preferences")
} finally { } finally {
setSaving(false) setSaving(false)
@@ -117,11 +118,13 @@ export function UserPreferencesForm() {
</Select> </Select>
</div> </div>
</div> </div>
<div className="flex justify-end pt-4"> <form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<Button onClick={handleSave} disabled={loading || saving}> <div className="flex justify-end pt-4">
{saving ? "Saving..." : "Save Preferences"} <Button type="submit" disabled={loading || saving}>
</Button> {saving ? "Saving..." : "Save Preferences"}
</div> </Button>
</div>
</form>
</CardContent> </CardContent>
</Card> </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({ const createSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"), 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"), role: z.string().min(1, "Role is required"),
active: z.boolean(), active: z.boolean(),
}); });
+1
View File
@@ -32,6 +32,7 @@ export function UsersTable({ data, loading, onUserDeleted }: UsersTableProps) {
toast.success("User deleted") toast.success("User deleted")
onUserDeleted?.() onUserDeleted?.()
} catch { } catch {
console.warn("Failed to delete user")
toast.error("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" 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`, { const res = await fetch(`${AI_SERVICE}/ai/chat`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message, user_id: userId, user_role: userRole }), body: JSON.stringify({ message }),
}) })
if (!res.ok) { if (!res.ok) {
@@ -23,30 +23,11 @@ export async function fetchJobs() {
const data = await res.json() const data = await res.json()
return data.jobs || [] return data.jobs || []
} catch { } catch {
console.warn("Failed to fetch AI jobs")
return [] 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() { export async function checkAiServiceStatus() {
try { try {
const res = await fetch(`${AI_SERVICE}/health`) const res = await fetch(`${AI_SERVICE}/health`)
@@ -54,6 +35,7 @@ export async function checkAiServiceStatus() {
const data = await res.json() const data = await res.json()
return data.status === "ok" return data.status === "ok"
} catch { } catch {
console.warn("Failed to check AI service status")
return false return false
} }
} }
+9 -9
View File
@@ -3,9 +3,11 @@ import bcrypt from "bcryptjs";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
const JWT_SECRET = new TextEncoder().encode( const RAW_SECRET = process.env.JWT_SECRET;
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production", 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 SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5; const MAX_FAILED_ATTEMPTS = 5;
@@ -174,7 +176,7 @@ export function mapDbUserToSessionUser(
SUPER_ADMIN: "super_admin", SUPER_ADMIN: "super_admin",
ADMIN: "admin", ADMIN: "admin",
SALES_USER: "sales", SALES_USER: "sales",
DEVELOPER: "sales", DEVELOPER: "dev",
}; };
const avatarUrl = dbUser.avatar_url as string | null const avatarUrl = dbUser.avatar_url as string | null
@@ -186,9 +188,7 @@ export function mapDbUserToSessionUser(
firstName: dbUser.first_name as string, firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string, lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(), role: roleMapping[roleName] || roleName.toLowerCase(),
avatar: avatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent( 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>`)}` })(),
`${dbUser.first_name}+${dbUser.last_name}`,
)}&background=1d4ed8&color=fff&size=128`,
}; };
} }
@@ -199,7 +199,7 @@ export async function createSession(userId: string, role: string) {
cookieStore.set(SESSION_COOKIE, token, { cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "strict",
path: "/", path: "/",
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24, // 24 hours
}); });
@@ -212,7 +212,7 @@ export async function destroySession() {
cookieStore.set(SESSION_COOKIE, "", { cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "strict",
path: "/", path: "/",
maxAge: 0, 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" 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({ const pool = new Pool({
connectionString: process.env.DATABASE_URL, connectionString: dbUrl,
max: 20, max: 20,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000, connectionTimeoutMillis: 5000,
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
}) })
pool.on("error", (err) => { pool.on("error", (err) => {
+11 -8
View File
@@ -2,15 +2,16 @@ import { NextResponse } from "next/server"
import type { NextRequest } from "next/server" import type { NextRequest } from "next/server"
import { jwtVerify } from "jose" import { jwtVerify } from "jose"
const JWT_SECRET = new TextEncoder().encode( const RAW_SECRET = process.env.JWT_SECRET
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production" if (!RAW_SECRET) {
) throw new Error("JWT_SECRET environment variable is required")
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [ const publicRoutes = [
"/login", "/login",
"/api/auth/login", "/api/auth/login",
"/api/auth/logout", "/api/auth/logout",
"/api/system",
"/_next/static", "/_next/static",
"/_next/image", "/_next/image",
"/favicon.ico", "/favicon.ico",
@@ -25,10 +26,6 @@ export async function middleware(request: NextRequest) {
(route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route) (route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route)
) )
if (pathname === "/api/auth/me") {
return NextResponse.next()
}
if (isPublic) { if (isPublic) {
return NextResponse.next() return NextResponse.next()
} }
@@ -36,6 +33,9 @@ export async function middleware(request: NextRequest) {
const sessionCookie = request.cookies.get("session")?.value const sessionCookie = request.cookies.get("session")?.value
if (!sessionCookie) { if (!sessionCookie) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url) const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname) loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl) return NextResponse.redirect(loginUrl)
@@ -45,6 +45,9 @@ export async function middleware(request: NextRequest) {
await jwtVerify(sessionCookie, JWT_SECRET) await jwtVerify(sessionCookie, JWT_SECRET)
return NextResponse.next() return NextResponse.next()
} catch { } catch {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url) const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname) loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl) return NextResponse.redirect(loginUrl)
+6 -6
View File
@@ -30,7 +30,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
setNotifications(data.notifications) setNotifications(data.notifications)
} }
} catch { } catch {
// ignore console.warn("Failed to fetch notifications in notification provider")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -51,7 +51,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
setUnreadChatCount(total) setUnreadChatCount(total)
} }
} catch { } catch {
// ignore console.warn("Failed to fetch unread conversations in notification provider")
} }
} }
fetchUnread() fetchUnread()
@@ -83,7 +83,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
body: JSON.stringify({ type, title, description, link }), body: JSON.stringify({ type, title, description, link }),
}) })
} catch { } catch {
// ignore console.warn("Failed to add notification in notification provider")
} }
}, },
[] []
@@ -97,7 +97,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try { try {
await fetch(`/api/notifications/${id}`, { method: "PATCH" }) await fetch(`/api/notifications/${id}`, { method: "PATCH" })
} catch { } catch {
// ignore console.warn("Failed to mark notification as read in notification provider")
} }
}, []) }, [])
@@ -106,7 +106,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try { try {
await fetch("/api/notifications", { method: "PATCH" }) await fetch("/api/notifications", { method: "PATCH" })
} catch { } 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 { try {
await fetch(`/api/notifications/${id}`, { method: "DELETE" }) await fetch(`/api/notifications/${id}`, { method: "DELETE" })
} catch { } 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) setUser(null)
} }
} catch { } catch {
console.warn("Failed to fetch user in user provider")
setUser(null) setUser(null)
} finally { } finally {
setLoading(false) setLoading(false)
@@ -53,7 +54,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
try { try {
await fetch("/api/auth/logout", { method: "POST" }) await fetch("/api/auth/logout", { method: "POST" })
} catch { } catch {
// Proceed with client-side logout even if API fails console.warn("Failed to logout in user provider")
} }
setUser(null) setUser(null)
router.push("/login") router.push("/login")