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