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