Push all current state

This commit is contained in:
2026-06-23 11:21:24 +02:00
parent 829fc3b008
commit 8d5bad87bc
69 changed files with 3282 additions and 1099 deletions
+15
View File
@@ -34,9 +34,24 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# runtime data
data/
data/ai/
data/ai/jobs.jsonl
# logs
*.log
*.out
error-log
# vercel
.vercel
# python
browser-use-service/venv/
browser-use-service/__pycache__/
browser-use-service/.env
# typescript
*.tsbuildinfo
next-env.d.ts
Binary file not shown.
+410
View File
@@ -0,0 +1,410 @@
import os, json, asyncio, re, shutil, sqlite3, traceback, urllib.parse, random, time, logging
from datetime import datetime, timedelta
from bs4 import BeautifulSoup
import httpx
from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn
from playwright.async_api import async_playwright
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
app = FastAPI()
PORT = int(os.getenv("PORT", "3008"))
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
FX_PROFILE = os.getenv('FX_PROFILE', '')
FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite') if FX_PROFILE else ''
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}
last_scrape_time: datetime | None = None
STRICT_KEYWORDS = [
"website", "web design", "web develop", "web dev",
"build my website", "build a website", "create a website",
"need web", "looking for web", "new website",
"landing page", "wordpress",
"need a website", "my website", "business website",
"need a designer", "help with my website",
"redesign", "update my website",
]
BROAD_KEYWORDS = STRICT_KEYWORDS + [
"looking for", "need a", "need an", "looking to",
"help me build", "create my", "build me",
"design my", "make me a", "would like",
"need someone", "hire a", "looking to hire",
"want someone", "need help with",
]
def kw_match_strict(text: str) -> bool:
t = text.lower()
return any(kw in t for kw in STRICT_KEYWORDS)
def kw_match(text: str) -> bool:
t = text.lower()
return any(kw in t for kw in BROAD_KEYWORDS)
FB_SEARCHES = [
"looking for web developer",
"need a website designed",
"need website built South Africa",
"need someone to build my website",
"need web designer",
"looking for someone to create website",
"need ecommerce website built",
"want to hire web developer",
"website quote please",
"need wordpress website",
"I need a website for my business",
"need a site for my business",
]
async def get_fb_cookies():
if not FX_COOKIE_DB or not os.path.exists(FX_COOKIE_DB):
logger.warning("FX_COOKIE_DB not found or FX_PROFILE not set")
return []
tmp = os.path.join(os.path.dirname(FX_COOKIE_DB), f'cookies_tmp_{os.getpid()}.sqlite')
for attempt in range(3):
try:
shutil.copy2(FX_COOKIE_DB, tmp)
break
except PermissionError:
if attempt < 2:
await asyncio.sleep(0.5)
else:
logger.error("Failed to copy cookie DB after 3 attempts")
return []
try:
conn = sqlite3.connect(tmp)
c = conn.cursor()
c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'")
rows = c.fetchall()
conn.close()
try:
os.remove(tmp)
except Exception:
pass
return [{
"name": name, "value": value,
"domain": host if host.startswith('.') else f'.{host}',
"path": path,
"httpOnly": True, "secure": True, "sameSite": "Lax",
} for name, value, host, path in rows]
except Exception as e:
logger.error("Cookie DB read error: %s", e)
try:
os.remove(tmp)
except Exception:
pass
return []
WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
def _parse_fb_date(block: list[str]) -> str:
for l in block:
l = l.strip()
m = re.match(r'(\d+)\s*(h|hr|hrs|hour|hours)\s*ago', l)
if m:
hours = int(m.group(1))
d = datetime.now() - timedelta(hours=hours)
return d.strftime('%Y-%m-%d')
if l.lower() == 'yesterday':
d = datetime.now() - timedelta(days=1)
return d.strftime('%Y-%m-%d')
low = l.lower()
if low in WEEKDAY_ORDER:
day_idx = WEEKDAY_ORDER.index(low)
today_idx = datetime.now().weekday()
days_ago = (today_idx - day_idx) % 7
d = datetime.now() - timedelta(days=days_ago)
return d.strftime('%Y-%m-%d')
for fmt in ['%Y-%m-%d', '%d %B %Y', '%B %d %Y', '%d %b %Y', '%b %d %Y', '%d %b', '%b %d']:
try:
dt = datetime.strptime(l, fmt)
return dt.strftime('%Y-%m-%d')
except ValueError:
pass
return datetime.now().strftime('%Y-%m-%d')
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
lines = [l.strip() for l in raw.split('\n')]
blocks = []
cur = []
fb_run = 0
for l in lines:
if 'facebook' in l.lower():
fb_run += 1
if cur and fb_run >= 2:
blocks.append(cur)
cur = []
else:
fb_run = 0
if l and len(l) >= 15:
words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) >= 2:
cur.append(l)
if cur:
blocks.append(cur)
posts = []
seen_texts = set()
for block in blocks:
if not block:
continue
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
if not kw_indices:
continue
i = kw_indices[0]
start = max(0, i - 2)
end = min(len(block), i + 5)
snippet = ' '.join(block[start:end])
if len(snippet) < 40:
continue
dekey = snippet[:80]
if dekey in seen_texts:
continue
seen_texts.add(dekey)
posts.append({
"title": snippet[:300],
"content": snippet[:1000],
"author": block[start] if start < i else '',
"url": url,
"source": "facebook",
"date": _parse_fb_date(block),
})
return posts
async def search_facebook(page, query: str) -> list[dict]:
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(6000)
except Exception as e:
logger.warning("Facebook search navigation failed for '%s': %s", query, e)
return []
try:
raw = await page.evaluate('document.body.innerText')
except Exception as e:
logger.warning("Failed to evaluate page text: %s", e)
return []
return _extract_posts_from_text(raw, url)
async def scrape_facebook() -> list[dict]:
all_posts = []
fb_cookies = await get_fb_cookies()
if not fb_cookies:
logger.warning("No Facebook cookies available")
return []
try:
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
viewport={'width': 1280, 'height': 800},
)
for c in fb_cookies:
try:
await context.add_cookies([c])
except Exception as e:
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
page = await context.new_page()
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(5000)
url = page.url
if '/login' in url.lower():
logger.warning("Facebook login page detected — cookies may be expired")
return []
for query in FB_SEARCHES[:6]:
try:
posts = await search_facebook(page, query)
all_posts.extend(posts)
delay = random.uniform(3, 7)
await page.wait_for_timeout(int(delay * 1000))
except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e)
except Exception as e:
logger.error("Facebook scrape failed: %s", e)
return []
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
return deduped[:20]
async def ask_ollama(prompt: str) -> str:
async with httpx.AsyncClient(timeout=120) as c:
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
"model": CLASSIFY_MODEL,
"messages": [
{"role": "system", "content": "You classify forum posts. Return only valid JSON."},
{"role": "user", "content": prompt}
],
"stream": False,
"options": {"temperature": 0.05, "num_predict": 1024},
})
r.raise_for_status()
data = r.json()
return data["message"]["content"]
async def scrape_warriorforum() -> list[dict]:
results = []
try:
async with httpx.AsyncClient(headers=HEADERS, timeout=15, follow_redirects=True) as c:
r = await c.get('https://www.warriorforum.com/wanted-members-looking-hire-you/')
soup = BeautifulSoup(r.text, 'lxml')
for row in soup.find_all('td', class_='FlexTable-item--title'):
a = row.find('a', href=True)
if not a:
continue
href = a['href']
title = a.text.strip()
if not title or len(title) < 15 or not kw_match_strict(title):
continue
author_div = row.find('div', class_='FlexTable-item-author')
author = author_div.get_text(strip=True).lstrip('by') if author_div else ''
date_str = ''
date_div = row.find('div', class_=lambda c: c and 'media--available' in c)
if date_div:
txt = date_div.get_text(strip=True)
m = re.search(r'(\d{10})', txt)
if m:
date_str = datetime.utcfromtimestamp(int(m.group(1))).strftime('%Y-%m-%d')
if not href.startswith('http'):
href = 'https://www.warriorforum.com' + href
results.append({
"title": title, "url": href,
"author": author,
"content": title[:300],
"source": "warriorforum",
"date": date_str
})
except Exception:
logger.error("WarriorForum scrape failed", exc_info=True)
return results
async def classify_leads(results: list[dict]) -> list[dict]:
if not results:
return []
briefs = [r["title"][:200] for r in results]
prompt = f"""Classify each post as LEAD or NOT.
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
LEAD examples: "Need a website for my business", "Looking for web developer to build my site", "I need someone to create my website", "Want a new website for my company", "Looking for someone to design my WordPress site"
NOT LEAD:
- Offering web design services: "I build websites", "I offer web design", "Affordable web design packages"
- Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
- Recruiting employees, hiring staff, looking for business partners
- Selling products, promoting services, affiliate offers
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
Return a JSON array like ["yes","no","yes"] matching the order above."""
ai_succeeded = False
try:
raw = await ask_ollama(prompt)
raw = raw.strip()
# Strip markdown code fences
if raw.startswith("```"):
# Remove opening fence
first_nl = raw.find('\n')
if first_nl != -1:
raw = raw[first_nl + 1:]
# Remove closing fence
if raw.endswith("```"):
raw = raw[:-3]
raw = raw.strip()
answers = json.loads(raw)
if isinstance(answers, list) and len(answers) == len(results):
ai_succeeded = True
filtered = []
for i, ans in enumerate(answers):
if isinstance(ans, str) and ans.lower() == 'yes':
filtered.append(results[i])
if filtered:
return filtered[:10]
# AI successfully classified but returned all "no" — respect that decision
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
return []
except Exception as e:
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
# Fallback: only use keyword filter when AI failed
if not ai_succeeded:
filtered = []
for r in results:
t = r['title'].lower()
if not kw_match_strict(t):
continue
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
'affordable web', 'web hosting', 'free website',
'limited time', 'special offer', 'sign up now',
'offer']):
continue
filtered.append(r)
return filtered[:10]
return []
@app.get("/health")
async def health():
return {"status": "ok"}
class ScrapeRequest(BaseModel):
query: str = ""
@app.post("/scrape/requests")
async def scrape_requests(req: ScrapeRequest):
global last_scrape_time
now = datetime.now()
if last_scrape_time and (now - last_scrape_time) < timedelta(seconds=15):
return {"error": "rate_limited", "retry_after": 15}
last_scrape_time = now
results = await scrape_warriorforum()
if results:
results = await classify_leads(results)
return results[:10]
@app.post("/scrape/facebook")
async def scrape_facebook_endpoint():
results = await scrape_facebook()
if results:
results = await classify_leads(results)
return results[:15]
@app.post("/scrape/all")
async def scrape_all():
results = []
try:
wf = await scrape_warriorforum()
if wf:
wf = await classify_leads(wf)
results.extend(wf[:5])
except Exception as e:
logger.error("WarriorForum scrape in /scrape/all failed: %s", e)
try:
fb = await scrape_facebook()
if fb:
fb = await classify_leads(fb)
results.extend(fb[:8])
except Exception as e:
logger.error("Facebook scrape in /scrape/all failed: %s", e)
return results[:12]
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=PORT)
@@ -0,0 +1,7 @@
python : INFO: Started server process [20044]
+ CategoryInfo : NotSpecified: (INFO: Started server process [20044]:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
@@ -0,0 +1,6 @@
[2026-06-22T19:24:48.030482] Browser Use service starting on port 3008
[2026-06-22T19:27:19.231701] Browser Use service starting on port 3008
[2026-06-22T19:28:28.335765] Browser Use service starting on port 3008
[2026-06-22T19:29:05.796265] Browser Use service starting on port 3008
[2026-06-22T19:29:17.042807] Scraping WarriorForum...
[2026-06-22T19:29:19.075166] WarriorForum: 55 results
@@ -0,0 +1,4 @@
INFO: Started server process [24268]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
@@ -0,0 +1,5 @@
Browser Use service starting on port 3008
INFO: 127.0.0.1:65003 - "GET /health HTTP/1.1" 200 OK
Scraping WarriorForum...
WarriorForum: 55 results
INFO: 127.0.0.1:54587 - "POST /scrape/all HTTP/1.1" 200 OK
+46 -41
View File
@@ -22,7 +22,7 @@ $$ LANGUAGE plpgsql;
-- 1. AUTHENTICATION & ROLE MANAGEMENT
-- ============================================================================
CREATE TABLE roles (
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
display_name VARCHAR(100),
@@ -35,7 +35,7 @@ CREATE TABLE roles (
CREATE UNIQUE INDEX uq_roles_name ON roles(name);
CREATE TABLE permissions (
CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
resource VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
@@ -46,7 +46,7 @@ CREATE TABLE permissions (
CREATE UNIQUE INDEX uq_permissions ON permissions(resource, action);
CREATE TABLE role_permissions (
CREATE TABLE IF NOT EXISTS role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
granted_by UUID,
@@ -54,7 +54,7 @@ CREATE TABLE role_permissions (
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE users (
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
@@ -83,7 +83,7 @@ CREATE INDEX idx_users_is_active ON users(is_active) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_is_locked ON users(is_locked) WHERE is_locked = TRUE AND deleted_at IS NULL;
CREATE INDEX idx_users_created_by ON users(created_by);
CREATE TABLE user_roles (
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id),
@@ -98,7 +98,7 @@ CREATE INDEX idx_user_roles_role ON user_roles(role_id);
-- 2. BAN & SUSPENSION SYSTEM
-- ============================================================================
CREATE TABLE banned_users (
CREATE TABLE IF NOT EXISTS banned_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
banned_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
banned_by UUID NOT NULL REFERENCES users(id),
@@ -122,7 +122,7 @@ CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
WHERE is_reversed = FALSE;
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
CREATE TABLE suspended_users (
CREATE TABLE IF NOT EXISTS suspended_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suspended_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
suspended_by UUID NOT NULL REFERENCES users(id),
@@ -145,7 +145,7 @@ CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
-- 3. SESSION & LOGIN MANAGEMENT
-- ============================================================================
CREATE TABLE sessions (
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
@@ -161,7 +161,7 @@ CREATE INDEX idx_sessions_user ON sessions(user_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX uq_sessions_token ON sessions(token_hash);
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE;
CREATE TABLE login_attempts (
CREATE TABLE IF NOT EXISTS login_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id),
username_attempted VARCHAR(100) NOT NULL,
@@ -181,7 +181,7 @@ CREATE INDEX idx_login_attempts_time ON login_attempts(attempted_at DESC);
-- 4. CUSTOMER MANAGEMENT
-- ============================================================================
CREATE TABLE customer_statuses (
CREATE TABLE IF NOT EXISTS customer_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -196,7 +196,7 @@ CREATE TABLE customer_statuses (
CREATE UNIQUE INDEX uq_customer_statuses_name ON customer_statuses(name) WHERE deleted_at IS NULL;
CREATE TABLE customers (
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_type VARCHAR(20) NOT NULL,
status_id UUID NOT NULL REFERENCES customer_statuses(id),
@@ -218,7 +218,7 @@ CREATE INDEX idx_customers_score ON customers(score DESC) WHERE deleted_at IS NU
CREATE INDEX idx_customers_tags ON customers USING GIN(tags) WHERE deleted_at IS NULL;
CREATE INDEX idx_customers_created ON customers(created_at DESC) WHERE deleted_at IS NULL;
CREATE TABLE individual_customers (
CREATE TABLE IF NOT EXISTS individual_customers (
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
@@ -233,7 +233,7 @@ CREATE TABLE individual_customers (
CREATE INDEX idx_individual_names ON individual_customers(last_name, first_name);
CREATE TABLE company_customers (
CREATE TABLE IF NOT EXISTS company_customers (
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
company_name VARCHAR(255) NOT NULL,
registration_number VARCHAR(100),
@@ -250,7 +250,7 @@ CREATE TABLE company_customers (
CREATE INDEX idx_company_name ON company_customers(company_name);
CREATE INDEX idx_company_industry ON company_customers(industry);
CREATE TABLE contact_information (
CREATE TABLE IF NOT EXISTS contact_information (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL,
@@ -270,7 +270,7 @@ CREATE INDEX idx_contacts_type ON contact_information(type) WHERE deleted_at IS
CREATE INDEX idx_contacts_value ON contact_information(value) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_contacts_primary ON contact_information(customer_id, type) WHERE is_primary = TRUE AND deleted_at IS NULL;
CREATE TABLE addresses (
CREATE TABLE IF NOT EXISTS addresses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
address_type VARCHAR(20) NOT NULL,
@@ -294,7 +294,7 @@ CREATE INDEX idx_addresses_country ON addresses(country) WHERE deleted_at IS NUL
CREATE INDEX idx_addresses_city ON addresses(city) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_addresses_primary ON addresses(customer_id, address_type) WHERE is_primary = TRUE AND deleted_at IS NULL;
CREATE TABLE customer_notes (
CREATE TABLE IF NOT EXISTS customer_notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id),
@@ -315,7 +315,7 @@ CREATE INDEX idx_notes_pinned ON customer_notes(is_pinned) WHERE is_pinned = TRU
-- 5. CUSTOMER & INCIDENT REPORTS
-- ============================================================================
CREATE TABLE customer_reports (
CREATE TABLE IF NOT EXISTS customer_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
reported_by UUID NOT NULL REFERENCES users(id),
@@ -339,7 +339,7 @@ CREATE INDEX idx_customer_reports_reported_by ON customer_reports(reported_by);
CREATE INDEX idx_customer_reports_status ON customer_reports(status);
CREATE INDEX idx_customer_reports_severity ON customer_reports(severity);
CREATE TABLE incident_reports (
CREATE TABLE IF NOT EXISTS incident_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reported_by UUID NOT NULL REFERENCES users(id),
target_user_id UUID REFERENCES users(id),
@@ -368,7 +368,7 @@ CREATE INDEX idx_incident_reports_severity ON incident_reports(severity);
-- 6. LEAD MANAGEMENT
-- ============================================================================
CREATE TABLE lead_sources (
CREATE TABLE IF NOT EXISTS lead_sources (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -380,7 +380,7 @@ CREATE TABLE lead_sources (
CREATE UNIQUE INDEX uq_lead_sources_name ON lead_sources(name) WHERE deleted_at IS NULL;
CREATE TABLE lead_stages (
CREATE TABLE IF NOT EXISTS lead_stages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -395,7 +395,7 @@ CREATE TABLE lead_stages (
CREATE UNIQUE INDEX uq_lead_stages_name ON lead_stages(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_lead_stages_sort ON lead_stages(sort_order);
CREATE TABLE leads (
CREATE TABLE IF NOT EXISTS leads (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
source_id UUID REFERENCES lead_sources(id),
stage_id UUID NOT NULL REFERENCES lead_stages(id),
@@ -431,7 +431,7 @@ CREATE INDEX idx_leads_converted ON leads(converted_customer_id) WHERE converted
CREATE INDEX idx_leads_email ON leads(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_leads_active ON leads(stage_id, assigned_to) WHERE deleted_at IS NULL AND converted_customer_id IS NULL;
CREATE TABLE lead_conversions (
CREATE TABLE IF NOT EXISTS lead_conversions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
@@ -447,7 +447,7 @@ CREATE INDEX idx_lead_conversions_customer ON lead_conversions(customer_id);
-- 7. PRODUCT CATALOG
-- ============================================================================
CREATE TABLE product_categories (
CREATE TABLE IF NOT EXISTS product_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
@@ -462,7 +462,7 @@ CREATE TABLE product_categories (
CREATE UNIQUE INDEX uq_product_categories_name ON product_categories(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_product_categories_parent ON product_categories(parent_id);
CREATE TABLE products (
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
category_id UUID REFERENCES product_categories(id),
name VARCHAR(255) NOT NULL,
@@ -487,7 +487,7 @@ CREATE INDEX idx_products_active ON products(is_active) WHERE deleted_at IS NULL
-- 8. SALES PIPELINE
-- ============================================================================
CREATE TABLE deal_stages (
CREATE TABLE IF NOT EXISTS deal_stages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -502,7 +502,7 @@ CREATE TABLE deal_stages (
CREATE UNIQUE INDEX uq_deal_stages_name ON deal_stages(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_deal_stages_sort ON deal_stages(sort_order);
CREATE TABLE opportunities (
CREATE TABLE IF NOT EXISTS opportunities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
lead_id UUID REFERENCES leads(id),
@@ -530,7 +530,7 @@ CREATE INDEX idx_opportunities_won ON opportunities(is_won) WHERE deleted_at IS
CREATE INDEX idx_opportunities_created ON opportunities(created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_opportunities_owner_stage ON opportunities(owner_id, stage_id) WHERE deleted_at IS NULL AND is_won IS NULL;
CREATE TABLE opportunity_products (
CREATE TABLE IF NOT EXISTS opportunity_products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id),
@@ -549,7 +549,7 @@ CREATE INDEX idx_opp_products_product ON opportunity_products(product_id);
-- 9. COMMUNICATION TRACKING
-- ============================================================================
CREATE TABLE communication_types (
CREATE TABLE IF NOT EXISTS communication_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
description TEXT,
@@ -561,7 +561,7 @@ CREATE TABLE communication_types (
CREATE UNIQUE INDEX uq_comm_types_name ON communication_types(name);
CREATE TABLE communications (
CREATE TABLE IF NOT EXISTS communications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
@@ -585,7 +585,7 @@ CREATE INDEX idx_communications_type ON communications(type_id) WHERE deleted_at
CREATE INDEX idx_communications_created_by ON communications(created_by) WHERE deleted_at IS NULL;
CREATE INDEX idx_communications_started ON communications(started_at) WHERE deleted_at IS NULL;
CREATE TABLE communication_participants (
CREATE TABLE IF NOT EXISTS communication_participants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
communication_id UUID NOT NULL REFERENCES communications(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
@@ -602,7 +602,7 @@ CREATE INDEX idx_comm_participants_user ON communication_participants(user_id);
-- 10. TASKS AND ACTIVITIES
-- ============================================================================
CREATE TABLE task_priorities (
CREATE TABLE IF NOT EXISTS task_priorities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
color VARCHAR(7),
@@ -613,7 +613,7 @@ CREATE TABLE task_priorities (
CREATE UNIQUE INDEX uq_task_priorities_name ON task_priorities(name);
CREATE TABLE tasks (
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
@@ -645,7 +645,7 @@ CREATE INDEX idx_tasks_reminder ON tasks(reminder_at) WHERE reminder_sent = FALS
-- 11. INVOICE SUPPORT
-- ============================================================================
CREATE TABLE invoice_statuses (
CREATE TABLE IF NOT EXISTS invoice_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
description TEXT,
@@ -657,7 +657,7 @@ CREATE TABLE invoice_statuses (
CREATE UNIQUE INDEX uq_invoice_statuses_name ON invoice_statuses(name);
CREATE TABLE invoices (
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
@@ -689,7 +689,7 @@ CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at
CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
-- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE)
CREATE TABLE invoice_items (
CREATE TABLE IF NOT EXISTS invoice_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id),
@@ -705,7 +705,7 @@ CREATE TABLE invoice_items (
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
CREATE INDEX idx_invoice_items_product ON invoice_items(product_id);
CREATE TABLE payments (
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id),
amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
@@ -729,7 +729,7 @@ CREATE INDEX idx_payments_transaction ON payments(transaction_id) WHERE transact
-- 12. AUDIT LOGGING
-- ============================================================================
CREATE TABLE audit_logs (
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
table_name VARCHAR(100) NOT NULL,
record_id UUID NOT NULL,
@@ -755,7 +755,7 @@ CREATE INDEX idx_audit_session ON audit_logs(session_id);
-- 13. DUPLICATE DETECTION
-- ============================================================================
CREATE TABLE customer_duplicates (
CREATE TABLE IF NOT EXISTS customer_duplicates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
duplicate_customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
@@ -810,11 +810,11 @@ BEGIN
)
LOOP
EXECUTE format(
'CREATE TRIGGER trg_%I_updated_at
'DROP TRIGGER IF EXISTS trg_%I_updated_at ON %I; CREATE TRIGGER trg_%I_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column()',
t, t
t, t, t, t
);
END LOOP;
END;
@@ -885,6 +885,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_enforce_create_user ON users;
CREATE TRIGGER trg_enforce_create_user
BEFORE INSERT ON users
FOR EACH ROW
@@ -935,6 +936,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_enforce_role_assignment ON user_roles;
CREATE TRIGGER trg_enforce_role_assignment
BEFORE INSERT ON user_roles
FOR EACH ROW
@@ -973,7 +975,7 @@ BEGIN
audit_action,
CASE WHEN audit_action IN ('UPDATE', 'DELETE') THEN old_row ELSE NULL END,
CASE WHEN audit_action IN ('CREATE', 'UPDATE') THEN new_row ELSE NULL END,
NULL
NULLIF(current_setting('app.current_user_id', true), '')
);
RETURN COALESCE(NEW, OLD);
@@ -998,6 +1000,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_ban ON banned_users;
CREATE TRIGGER trg_audit_ban
AFTER INSERT OR UPDATE ON banned_users
FOR EACH ROW
@@ -1021,6 +1024,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_suspension ON suspended_users;
CREATE TRIGGER trg_audit_suspension
AFTER INSERT OR UPDATE ON suspended_users
FOR EACH ROW
@@ -1051,6 +1055,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
CREATE TRIGGER trg_audit_login
AFTER INSERT ON login_attempts
FOR EACH ROW
@@ -29,6 +29,8 @@ CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_participants_user_id ON conversation_participants(user_id);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at DESC);
-- Seed conversations between superadmin and other users
INSERT INTO conversations (id, created_at) VALUES
('c0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 hours'),
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS company_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_name VARCHAR(255) NOT NULL DEFAULT '',
company_email VARCHAR(255) NOT NULL DEFAULT '',
company_phone VARCHAR(50) NOT NULL DEFAULT '',
company_website VARCHAR(255) NOT NULL DEFAULT '',
company_address TEXT NOT NULL DEFAULT '',
updated_by UUID REFERENCES users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address)
VALUES ('Coastal IT Solutions', 'info@coastalit.com', '(555) 123-4567', 'https://coastalit.com', '123 Business Ave, Suite 100, San Francisco, CA 94105')
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS user_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
timezone VARCHAR(100) NOT NULL DEFAULT 'america-los_angeles',
date_format VARCHAR(10) NOT NULL DEFAULT 'mdy',
items_per_page INTEGER NOT NULL DEFAULT 20,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,4 @@
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_id UUID;
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id) WHERE context_type IS NOT NULL;
+10 -6
View File
@@ -4,6 +4,9 @@
-- Usage: psql -U postgres -d crm -f run_all.sql
-- ============================================================================
BEGIN;
\set ON_ERROR_STOP on
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
\i 001_schema.sql
@@ -28,10 +31,11 @@
\echo '=== Running 008_notifications.sql (Notifications + Preferences) ==='
\i 008_notifications.sql
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
\i 009_settings.sql
\echo '=== Running 010_chat_notifications.sql (Chat Message Notifications) ==='
\i 010_chat_notifications.sql
\echo '=== Migration Complete ==='
\echo ''
\echo 'Test accounts created:'
\echo ' superadmin_demo / SuperAdmin@2026'
\echo ' admin_demo / AdminAccess@2026'
\echo ' sales_demo / SalesAccess@2026'
\echo ' dev_demo / DevTesting@2026'
COMMIT;
+49 -38
View File
@@ -1,60 +1,71 @@
# Facebook Scraper - Configuration Needed
# Scrapers - Configuration & Status
## Proxy Configuration
## Facebook Scraper
**File:** `rust-ai/src/main.rs`
**Lines:** 25-27
The scraper needs real proxy URLs. The dummy placeholder `"http://0.0.0.0:0"` will fail to connect (expected — no crash, just error logs):
**Lines:** ~70-85
Target URL (line 72):
```rust
const PROXIES: &[&str] = &[
"http://0.0.0.0:0", // <- Replace with real proxy(es)
];
let url = "https://www.facebook.com/search/top/?q=need%20website%20create";
```
Replace with your actual proxies, e.g.:
```rust
const PROXIES: &[&str] = &[
"http://user:pass@192.168.1.1:8080",
"http://user:pass@192.168.1.2:8080",
];
**Status:** Uses direct connection (no proxy) — your home IP. Facebook blocks datacenter IPs. May work from a residential connection.
**Test with curl:**
```
curl.exe -s "https://www.facebook.com/search/top/?q=need%20website%20create" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" --max-time 10 2>$null
```
## User Agents (Optional)
**Expected log output when blocked:**
```
ERROR crm_ai: Facebook scraper error: error sending request for url (https://www.facebook.com/search/top/?q=need%20website%20create)
```
## Reddit Scraper
**File:** `rust-ai/src/main.rs`
**Lines:** 32-36
**Lines:** ~90-120
Add more realistic user agents here if needed.
Uses `old.reddit.com` (older design that allows scraping without captchas).
## Scraper Target URL
Search queries (currently 6, lines 93-100):
- `r/southafrica` — "need website", "web developer"
- Global — '"need a website"', "website quote"
- `r/forhire` — "website"
- `r/smallbusiness` — "website"
**Status:** Working. Reddit results appear as `INFO LEAD:` entries in the server log.
**Test with curl:**
```
curl.exe -s "https://old.reddit.com/r/southafrica/search?q=need+website&sort=new&restrict_sr=on&t=week" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
```
**Expected log output when working:**
```
INFO crm_ai: LEAD: [Post Title] -> https://old.reddit.com/r/.../...
```
## Background Loop
**File:** `rust-ai/src/main.rs`
**Line:** 68
**Lines:** ~370-383
Currently fetches `https://www.facebook.com/search/top/?q=need%20website%20create`. Change if needed.
Both scrapers run together every 60-180 seconds on a `spawn_blocking` thread.
## Background Thread
## What You Need to Do
**File:** `rust-ai/src/main.rs`
**Lines:** 355-371
### Facebook
- Try running from your home PC instead — your residential IP may not be blocked
- If still blocked, you need residential proxies (BrightData, IPRoyal, Oxylabs)
- Configure them in the `PROXIES` array at line 25
Runs in a `tokio::task::spawn_blocking` thread (changed from `tokio::spawn` because the scraper uses `reqwest::blocking::Client` + `thread::sleep`).
### Reddit
- Works out of the box via `old.reddit.com`
- If it stops working, Reddit IP-blocked you — use proxies or switch to Playwright
## Expected Behavior Until Configured
### To add more search queries
Edit the `searches` array in `run_reddit_scraper()` (line 93).
Until real proxies are set, the Rust server will log this every 1-5 seconds (not a crash):
```
ERROR crm_ai: Facebook scraper error: error sending request for url (https://www.facebook.com/messages)
```
Once you add working proxies, these errors stop and actual scraping begins.
## How it works
- Runs every 1-5 seconds on a background blocking thread (line 355-371)
- Rotates through proxies and user agents for each request
- Parses conversation HTML via `scraper` crate
- Designed to detect job posts like "need a website" and notify sales reps
- Error handling added to prevent server crash (uses `match` instead of `.unwrap()` at line 69)
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true,
ignoreDuringBuilds: false,
},
images: {
remotePatterns: [
+4 -4
View File
@@ -4,13 +4,14 @@
"private": true,
"scripts": {
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:start": "concurrently -n AI,NEXT -c cyan,green \"npm run dev:rust\" \"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:precheck": "powershell -NoProfile -Command \"$targetPorts=3001,3006; netstat -ano | Select-String LISTENING | ForEach-Object { $line=$_.ToString(); foreach($p in $targetPorts){ if($line -match ('[:]'+$p+'\\s')){ $foundPid=($line -split '\\s+')[-1]; try{ Stop-Process -Id $foundPid -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$p) }catch{} } } }; exit 0\"",
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
"dev:ollama": "powershell -NoProfile -Command \"if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
"dev:rust": "cd rust-ai && cargo run",
"dev:browser-use": "cd browser-use-service && python main.py",
"build": "next build",
"start": "npm run dev:next",
"start": "next start -p 3006",
"lint": "eslint"
},
"dependencies": {
@@ -36,7 +37,6 @@
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"framer-motion": "^11.15.0",
"jose": "^6.2.3",
"lucide-react": "^0.468.0",
+137 -482
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,7 +6,7 @@ description = "AI Sales Assistant backend for Coast IT CRM"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -17,5 +17,5 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.5", features = ["cors"] }
dotenvy = "0.15"
scraper = "0.12"
rand = "0.8"
jsonwebtoken = "9"
-85
View File
@@ -1,85 +0,0 @@
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{error, info, warn};
/// Manages the ai.md self-improvement file.
/// Loaded on startup and periodically refreshed.
pub struct InstructionsManager {
path: PathBuf,
current: RwLock<String>,
}
impl InstructionsManager {
pub fn new(path: PathBuf) -> Self {
let initial = fs::read_to_string(&path).unwrap_or_default();
if initial.is_empty() {
warn!("ai.md is empty or missing at {:?}", path);
} else {
info!("Loaded ai.md ({} bytes)", initial.len());
}
Self {
path,
current: RwLock::new(initial),
}
}
/// Read the current instructions
pub async fn get(&self) -> String {
self.current.read().await.clone()
}
/// Append a new entry to the Improvement Log and optionally update the instructions.
/// Returns the updated full content.
pub async fn update(&self, entry: &str, new_content: Option<&str>) -> Result<String, String> {
if let Some(content) = new_content {
// Full replacement
fs::write(&self.path, content).map_err(|e| format!("Write failed: {}", e))?;
let mut current = self.current.write().await;
*current = content.to_string();
info!("ai.md fully replaced ({} bytes)", content.len());
Ok(content.to_string())
} else {
// Append to Improvement Log only
let mut current = self.current.write().await;
let log_entry = format!("\n- {}{}", chrono::Utc::now().format("%Y-%m-%d %H:%M"), entry);
// Find the Improvement Log section and append
if let Some(pos) = current.rfind("\n## Improvement Log") {
// Find the next section after Improvement Log, or end of file
let after_log = &current[pos..];
if let Some(section_start) = after_log[1..].find("\n## ") {
let insert_at = pos + 1 + section_start;
current.insert_str(insert_at, &format!("{}\n", log_entry));
} else {
current.push_str(&format!("{}\n", log_entry));
}
} else {
current.push_str(&format!("\n## Improvement Log\n{}", log_entry));
}
fs::write(&self.path, &*current).map_err(|e| format!("Write failed: {}", e))?;
info!("ai.md improvement log appended: {}", entry);
Ok(current.clone())
}
}
/// Reload from disk
pub async fn reload(&self) {
match fs::read_to_string(&self.path) {
Ok(content) => {
let len = content.len();
let mut current = self.current.write().await;
*current = content;
info!("ai.md reloaded from disk ({} bytes)", len);
}
Err(e) => error!("Failed to reload ai.md: {}", e),
}
}
}
/// Wrapper for thread-safe sharing
pub type SharedInstructions = Arc<InstructionsManager>;
pub fn create_shared(path: PathBuf) -> SharedInstructions {
Arc::new(InstructionsManager::new(path))
}
+282 -158
View File
@@ -1,93 +1,67 @@
use axum::{
extract::State,
http::Method,
http::{HeaderMap, Method, StatusCode},
routing::{get, post},
Json, Router,
};
use tower_http::cors::{CorsLayer, AllowOrigin, Any};
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use std::collections::HashMap;
use std::fs;
use std::sync::{Arc, Mutex};
use tower_http::cors::{Any, CorsLayer};
use tracing::{error, info};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
use uuid::Uuid;
use reqwest::blocking::Client;
use scraper::{Html, Selector};
use rand::rngs::StdRng;
use rand::SeedableRng;
use rand::Rng;
use std::time::Duration;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
// ── Facebook Scraper ───────────────────────────────────────────
// ── JWT Claims ────────────────────────────────────────────────
// List of proxies
const PROXIES: &[&str] = &[
"http://user:pass@192.168.1.1:8080",
"http://user:pass@192.168.1.2:8080",
"http://user:pass@192.168.1.3:8080",
"http://user:pass@192.168.1.4:8080",
"http://user:pass@192.168.1.5:8080",
];
// List of user agents
const USER_AGENTS: &[&str] = &[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:61.0) Gecko/20100101 Firefox/61.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36",
// Add more user agents here
];
fn get_random_proxy() -> String {
let mut rng = rand::thread_rng();
PROXIES[rng.gen_range(0..PROXIES.len())].to_string()
#[derive(Debug, Deserialize)]
struct Claims {
#[serde(rename = "userId")]
user_id: String,
role: String,
}
fn get_random_user_agent() -> String {
let mut rng = rand::thread_rng();
USER_AGENTS[rng.gen_range(0..USER_AGENTS.len())].to_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)
}
fn scrape_conversations(url: &str) -> Result<Html, Box<dyn std::error::Error>> {
let proxy = get_random_proxy();
let user_agent = get_random_user_agent();
let client = Client::builder()
.proxy(reqwest::Proxy::all(proxy)?)
.build()?;
// ── Rate limiter ──────────────────────────────────────────────
let response = client.get(url)
.header("User-Agent", user_agent)
.send()?;
struct RateLimiter {
buckets: Mutex<HashMap<String, Vec<u64>>>,
max_requests: usize,
window_secs: u64,
}
if response.status().is_success() {
let html = response.text()?;
Ok(Html::parse_document(&html))
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 {
Err(format!("Failed to retrieve the page: {}", response.status()).into())
}
}
fn run_facebook_scraper() {
let url = "https://www.facebook.com/search/top/?q=need%20website%20create";
match scrape_conversations(url) {
Ok(soup) => {
// Example: Extract conversation data
let selector = Selector::parse("div.conversation").unwrap();
for element in soup.select(&selector) {
println!("Conversation: {}", element.inner_html());
timestamps.push(now);
true
}
}
Err(e) => {
error!("Facebook scraper error: {}", e);
}
}
// Introduce random delay
let mut rng = rand::thread_rng();
let delay = rng.gen_range(1..5); // Delay between 1 to 5 seconds
thread::sleep(Duration::from_secs(delay));
}
// ── Shared state ───────────────────────────────────────────────
@@ -97,6 +71,48 @@ struct AppState {
ollama_url: String,
model: String,
jobs: Vec<Job>,
leads: Arc<Mutex<LeadStore>>,
http_client: reqwest::Client,
jwt_secret: String,
rate_limiter: RateLimiter,
}
#[derive(Debug, Clone, Serialize)]
struct Lead {
title: String,
url: String,
source: String,
found_at: u64,
author: String,
date: String,
content: String,
}
struct LeadStore {
leads: Vec<Lead>,
max_size: usize,
}
impl LeadStore {
fn new(max_size: usize) -> Self {
Self { leads: Vec::new(), max_size }
}
fn push(&mut self, lead: Lead) {
if !self.leads.iter().any(|l| l.url == lead.url) {
self.leads.insert(0, lead);
self.leads.truncate(self.max_size);
}
}
fn recent(&self, max_age_secs: u64, limit: usize) -> Vec<Lead> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
self.leads.iter()
.filter(|l| now.saturating_sub(l.found_at) <= max_age_secs)
.take(limit)
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -110,8 +126,6 @@ struct Job {
#[derive(Debug, Deserialize)]
struct ChatRequest {
message: String,
user_id: String,
user_role: String,
}
#[derive(Debug, Serialize)]
@@ -162,23 +176,73 @@ struct OllamaResponseMessage {
content: String,
}
// ── System prompt builder ─────────────────────────────────────
// ── Helpers ────────────────────────────────────────────────────
fn build_system_prompt(jobs: &[Job]) -> String {
fn truncate(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (StatusCode, String)> {
let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()).unwrap_or("");
let token = auth_header.strip_prefix("Bearer ").unwrap_or("");
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
})?;
match claims.role.as_str() {
"sales" | "admin" | "super_admin" => Ok(claims),
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
}
}
fn format_leads_output(leads: &[Lead]) -> String {
if leads.is_empty() {
return "No new requests found yet.".to_string();
}
leads
.iter()
.enumerate()
.map(|(i, l)| {
let author = if l.author.is_empty() { "Unknown" } else { &l.author };
let date = truncate(&l.date, 10);
format!(
"{}. {}\n {}\n {}\n {}",
i + 1,
author,
date,
l.title,
l.url
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
let job_list: Vec<String> = jobs
.iter()
.map(|j| format!("- {} ({}): {}", j.job_title, j.industry, j.description))
.collect();
let job_list_str = job_list.join("\n");
let lead_summary: Vec<String> = leads
.iter()
.map(|l| format!("{} | {} | {}", l.author, l.title, l.url))
.collect();
let lead_summary_str = if lead_summary.is_empty() {
"None yet.".to_string()
} else {
lead_summary.join("\n")
};
format!(
"You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople \
with tips, strategies, and guidance.\n\n\
"You are a Sales AI Assistant for Coast IT CRM.\n\n\
Available job categories to target:\n{}\n\n\
Provide concise, actionable sales advice. When asked about a specific job category, \
give targeted tips on finding and engaging prospects in that field. \
Keep responses under 300 words unless asked for detail.",
job_list_str
Recent leads context:\n{}\n\n\
Rules:\n\
- When asked about leads, answer concisely under 150 words.\n\
- If asked to suggest a sales strategy, give brief actionable advice.\n\
- Be direct and professional. No fluff.",
job_list_str, lead_summary_str
)
}
@@ -186,90 +250,119 @@ fn build_system_prompt(jobs: &[Job]) -> String {
async fn handle_chat(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ChatRequest>,
) -> Result<Json<ChatResponse>, (axum::http::StatusCode, String)> {
// Validate role
match req.user_role.as_str() {
"sales" | "admin" | "super_admin" => {}
_ => return Err((axum::http::StatusCode::FORBIDDEN, "Forbidden".to_string())),
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
let claims = extract_claims(&headers, &state)?;
if !state.rate_limiter.check(&claims.user_id).await {
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
}
let system_prompt = build_system_prompt(&state.jobs);
let msg_lower = req.message.to_lowercase();
let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
let has_listing = msg_words.iter().any(|w| ["listings", "listing", "leads", "links", "lists"].contains(w));
let has_show = msg_lower.contains("show me") || msg_lower.contains("give me") || msg_lower.contains("pull");
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let service_url = "http://localhost:3008/scrape/all".to_string();
if let Ok(resp) = state.http_client
.post(&service_url)
.timeout(Duration::from_secs(120))
.send()
.await
{
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await {
info!("Scraped {} leads from {}", leads_data.len(), service_url);
let mut store = state.leads.lock().await;
for item in &leads_data {
store.push(Lead {
title: truncate(item["title"].as_str().unwrap_or(""), 120),
url: item["url"].as_str().unwrap_or("").to_string(),
source: item["source"].as_str().unwrap_or("unknown").to_string(),
found_at: now,
author: truncate(item["author"].as_str().unwrap_or(""), 60),
date: truncate(item["date"].as_str().unwrap_or(""), 30),
content: truncate(item["content"].as_str().unwrap_or(""), 300),
});
}
}
} else {
error!("Scraper service unreachable: {}", service_url);
}
let recent_leads = state.leads.lock().await.recent(604800, 20);
let response = format_leads_output(&recent_leads);
let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&claims.role)
.bind(&req.message)
.bind(&response)
.execute(&state.db)
.await;
return Ok(Json(ChatResponse { response }));
}
let recent_leads = state.leads.lock().await.recent(604800, 20);
let system_prompt = build_system_prompt(&state.jobs, &recent_leads);
let message_text = req.message.clone();
let ollama_req = OllamaRequest {
model: state.model.clone(),
messages: vec![
OllamaChatMessage {
role: "system".to_string(),
content: system_prompt,
},
OllamaChatMessage {
role: "user".to_string(),
content: req.message.clone(),
},
OllamaChatMessage { role: "system".to_string(), content: system_prompt },
OllamaChatMessage { role: "user".to_string(), content: req.message.clone() },
],
stream: false,
options: OllamaOptions {
temperature: 0.7,
num_predict: 1024,
},
options: OllamaOptions { temperature: 0.7, num_predict: 1024 },
};
let client = reqwest::Client::new();
let resp = client
let resp = state.http_client
.post(format!("{}/api/chat", state.ollama_url))
.json(&ollama_req)
.send()
.await
.map_err(|e| {
error!("Ollama request failed: {}", e);
(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"AI service unavailable".to_string(),
)
(StatusCode::SERVICE_UNAVAILABLE, "AI service unavailable".to_string())
})?;
let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| {
error!("Failed to parse Ollama response: {}", e);
(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"AI response parse error".to_string(),
)
(StatusCode::SERVICE_UNAVAILABLE, "AI response parse error".to_string())
})?;
let response_text = ollama_resp
.message
.map(|m| m.content)
.unwrap_or_default();
let response_text = ollama_resp.message.map(|m| m.content).unwrap_or_default();
// Store in database
let user_id = Uuid::parse_str(&req.user_id).unwrap_or(Uuid::nil());
let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(user_id)
.bind(&req.user_role)
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&claims.role)
.bind(&message_text)
.bind(&response_text)
.execute(&state.db)
.await;
Ok(Json(ChatResponse {
response: response_text,
}))
Ok(Json(ChatResponse { response: response_text }))
}
// ── Jobs handler ───────────────────────────────────────────────
async fn handle_jobs(
State(state): State<Arc<AppState>>,
) -> Json<JobsResponse> {
Json(JobsResponse {
jobs: state.jobs.clone(),
})
headers: HeaderMap,
) -> Result<Json<JobsResponse>, (StatusCode, String)> {
let _claims = extract_claims(&headers, &state)?;
if !state.rate_limiter.check(&_claims.user_id).await {
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
}
Ok(Json(JobsResponse { jobs: state.jobs.clone() }))
}
// ── Health handler ─────────────────────────────────────────────
@@ -296,52 +389,50 @@ async fn main() {
dotenvy::dotenv().ok();
let database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let ollama_url =
std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "sam860/dolphin3-llama3.2:3b".to_string());
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let port: u16 = std::env::var("AI_PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.expect("AI_PORT must be a number");
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
// Load jobs
let jobs_path = std::env::var("JOBS_PATH").unwrap_or_else(|_| "data/ai/jobs.jsonl".to_string());
let jobs_content = fs::read_to_string(&jobs_path).unwrap_or_default();
let jobs: Vec<Job> = jobs_content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
let jobs: Vec<Job> = jobs_content.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect();
info!(
"Loaded {} job categories, model: {}, Ollama: {}",
jobs.len(),
model,
ollama_url
);
info!("Loaded {} job categories, model: {}, Ollama: {}", jobs.len(), model, ollama_url);
// Connect to database
let db = PgPoolOptions::new()
.max_connections(5)
.max_connections(20)
.connect(&database_url)
.await
.expect("Failed to connect to database");
info!("Connected to PostgreSQL");
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("Failed to build HTTP client");
let lead_store = Arc::new(Mutex::new(LeadStore::new(100)));
let state = Arc::new(AppState {
db,
ollama_url,
model,
jobs,
leads: lead_store.clone(),
http_client,
jwt_secret,
rate_limiter: RateLimiter::new(30, 60),
});
// CORS layer
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_origin(AllowOrigin::list([
"http://localhost:3006".parse().unwrap(),
"http://127.0.0.1:3006".parse().unwrap(),
]))
.allow_methods([Method::GET, Method::POST])
.allow_headers(Any);
@@ -359,20 +450,53 @@ async fn main() {
.await
.expect("Failed to bind address");
// Start Facebook scraper in a separate thread
let rng = Arc::new(Mutex::new(StdRng::from_entropy()));
tokio::task::spawn_blocking(move || {
loop {
let bg_leads = lead_store.clone();
let bg_url = "http://localhost:3008/scrape/all".to_string();
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
{
let _lock = rng.lock().unwrap();
run_facebook_scraper();
Ok(c) => c,
Err(e) => {
error!("Failed to build background HTTP client: {} — scraper disabled", e);
return;
}
// Introduce random delay
let delay = {
let mut rng = rng.lock().unwrap();
rng.gen_range(1..5)
};
thread::sleep(Duration::from_secs(delay));
loop {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
match client.post(&bg_url).send().await {
Ok(resp) => {
if resp.status().is_success() {
match resp.json::<Vec<serde_json::Value>>().await {
Ok(data) => {
let mut store = bg_leads.lock().await;
for item in &data {
store.push(Lead {
title: truncate(item["title"].as_str().unwrap_or(""), 120),
url: item["url"].as_str().unwrap_or("").to_string(),
source: item["source"].as_str().unwrap_or("unknown").to_string(),
found_at: now,
author: truncate(item["author"].as_str().unwrap_or(""), 60),
date: truncate(item["date"].as_str().unwrap_or(""), 30),
content: truncate(item["content"].as_str().unwrap_or(""), 300),
});
}
}
Err(e) => {
warn!("Failed to parse scraper JSON: {}", e);
}
}
} else {
warn!("Scraper returned status: {}", resp.status());
}
}
Err(e) => {
warn!("Scraper request failed: {}", e);
}
}
let delay = rand::thread_rng().gen_range(120..300);
tokio::time::sleep(Duration::from_secs(delay)).await;
}
});
+100
View File
@@ -0,0 +1,100 @@
const express = require('express');
const { chromium } = require('playwright');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3007;
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.post('/scrape/indeed', async (req, res) => {
const results = [];
let browser;
try {
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const queries = [
'need a website built',
'looking for someone to build my website',
'need web designer',
'looking for web developer',
'need help with my website',
'need ecommerce website',
'need wordpress website',
'looking for website designer',
];
for (const q of queries) {
const url = `https://www.indeed.com/jobs?q=${encodeURIComponent(q)}&sort=date`;
try {
const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForTimeout(2000);
const items = await page.evaluate(() => {
const cards = document.querySelectorAll('.job_seen_beacon');
return Array.from(cards).slice(0, 8).map(card => {
const titleEl = card.querySelector('.jcs-JobTitle');
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]');
const snippetEl = card.querySelector('.job-snippet');
return {
title: titleEl?.textContent?.trim() || '',
url: titleEl?.href || '',
company: companyEl?.textContent?.trim() || '',
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
};
});
});
for (const item of items) {
if (item.title && item.url && !results.some(r => r.url === item.url)) {
results.push({
title: item.title,
url: item.url,
author: item.company,
date: '',
content: item.snippet,
source: 'indeed',
});
}
}
await page.close();
} catch (e) {
console.error(`Indeed failed ${q}:`, e.message);
}
}
await browser.close();
res.json(results.slice(0, 50));
} catch (e) {
if (browser) await browser.close().catch(() => {});
console.error('Indeed error:', e.message);
res.status(500).json({ error: e.message });
}
});
app.post('/scrape/all', async (req, res) => {
try {
const [indeed] = await Promise.allSettled([
fetch(`http://localhost:${PORT}/scrape/indeed`, { method: 'POST' }).then(r => r.json()).catch(() => []),
]);
const all = [
...(indeed.status === 'fulfilled' ? indeed.value : []),
];
console.log(`Scrape all: ${all.length} leads`);
res.json(all);
} catch (e) {
console.error('Scrape all error:', e.message);
res.status(500).json({ error: e.message });
}
});
app.listen(PORT, () => {
console.log(`Scraper service running on port ${PORT}`);
});
+872
View File
@@ -0,0 +1,872 @@
{
"name": "scraper-service",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scraper-service",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.48.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "scraper-service",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node index.js",
"install-playwright": "npx playwright install chromium"
},
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.48.0"
}
}
+73 -11
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useRef, useCallback, useEffect } from "react"
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -90,6 +90,9 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
)
}
const MAX_CACHED_CONVERSATIONS = 5
const otherParticipant = (conv: any) => conv.otherUser
export default function ChatsPage() {
const { theme } = useTheme()
const { user } = useUser()
@@ -129,6 +132,8 @@ export default function ChatsPage() {
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const recordingChunksRef = useRef<Blob[]>([])
const resizeStartRef = useRef({ x: 0, width: 0 })
const blobUrlsRef = useRef<string[]>([])
const conversationOrderRef = useRef<string[]>([])
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
@@ -141,12 +146,12 @@ export default function ChatsPage() {
if (!user) return null
const messages = conversationMessages.get(activeChat || "") || []
const conversation = conversations.find((c) => c.id === activeChat)
const otherParticipant = (conv: any) => conv.otherUser
const filteredConversations = searchQuery.trim()
? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
: conversations
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const filteredConversations = useMemo(() => {
if (!searchQuery.trim()) return conversations
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
}, [conversations, searchQuery])
// Fetch conversations from API
useEffect(() => {
@@ -161,7 +166,7 @@ export default function ChatsPage() {
if (convs.length > 0) setActiveChat(convs[0].id)
}
} catch {
// ignore
console.warn("Failed to fetch conversations in chats page")
} finally {
setLoadingChats(false)
}
@@ -172,6 +177,10 @@ export default function ChatsPage() {
// Fetch messages when active chat changes, and mark as read
useEffect(() => {
if (!activeChat) return
conversationOrderRef.current = [
activeChat,
...conversationOrderRef.current.filter((id) => id !== activeChat),
]
const fetchMsgs = async () => {
try {
const res = await fetch(`/api/conversations/${activeChat}/messages`)
@@ -180,6 +189,30 @@ export default function ChatsPage() {
setConversationMessages((prev) => {
const next = new Map(prev)
next.set(activeChat, data.messages || [])
const recent = conversationOrderRef.current.slice(0, MAX_CACHED_CONVERSATIONS)
const recentSet = new Set(recent)
for (const key of next.keys()) {
if (!recentSet.has(key)) next.delete(key)
}
const validMsgIds = new Set<string>()
for (const msgs of next.values()) {
for (const m of msgs) validMsgIds.add(m.id)
}
setVoiceMessages((v) => {
const n = new Map(v)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
return n
})
setMessageAttachments((a) => {
const n = new Map(a)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
return n
})
setReplyMap((r) => {
const n = new Map(r)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
return n
})
return next
})
}
@@ -206,7 +239,7 @@ export default function ChatsPage() {
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
}
} catch { /* ignore */ }
} catch { console.warn("Failed to search users in chats page") }
setSearchingUsers(false)
}, 300)
return () => clearTimeout(timer)
@@ -222,6 +255,14 @@ export default function ChatsPage() {
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
// revoke blob URLs on unmount
useEffect(() => {
return () => {
blobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
blobUrlsRef.current = []
}
}, [])
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
@@ -254,8 +295,16 @@ export default function ChatsPage() {
setTimeout(() => autoResizeTextarea(), 0)
}
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
if (oversized.length > 0) {
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
if (e.target) e.target.value = ""
return
}
setAttachments((prev) => [...prev, ...files])
if (e.target) e.target.value = ""
}
@@ -319,6 +368,7 @@ export default function ChatsPage() {
})
}
} catch {
console.warn("Failed to send message in chats page")
toast.error("Failed to send message")
}
}
@@ -341,7 +391,11 @@ export default function ChatsPage() {
return
}
const fileAttachments = attachments.length > 0
? attachments.map((f) => ({ name: f.name, type: f.type, url: URL.createObjectURL(f) }))
? attachments.map((f) => {
const url = URL.createObjectURL(f)
blobUrlsRef.current.push(url)
return { name: f.name, type: f.type, url }
})
: undefined
if (replyingTo) {
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
@@ -356,6 +410,10 @@ export default function ChatsPage() {
}
const handleDeleteMessage = (messageId: string) => {
const files = messageAttachments.get(messageId)
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
const voice = voiceMessages.get(messageId)
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
setConversationMessages((prev) => {
const next = new Map(prev)
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
@@ -383,6 +441,7 @@ export default function ChatsPage() {
recorder.onstop = () => {
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
const url = URL.createObjectURL(blob)
blobUrlsRef.current.push(url)
addMessageToChat("", { url, duration: recordingDurationRef.current })
stream.getTracks().forEach((t) => t.stop())
setRecordingDuration(0)
@@ -392,6 +451,7 @@ export default function ChatsPage() {
setIsRecording(true)
setIsPaused(false)
} catch {
console.warn("Failed to start recording in chats page")
toast.error("Microphone access denied")
}
}
@@ -425,6 +485,7 @@ export default function ChatsPage() {
setIsPaused(false)
setRecordingDuration(0)
recordingDurationRef.current = 0
recordingChunksRef.current = []
}
useEffect(() => {
@@ -532,7 +593,7 @@ export default function ChatsPage() {
setSearchQuery("")
}
}
} catch { /* ignore */ }
} catch { console.warn("Failed to create conversation in chats page") }
}}
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
>
@@ -911,6 +972,7 @@ export default function ChatsPage() {
toast.success("Message forwarded")
}
} catch {
console.warn("Failed to forward message in chats page")
toast.error("Failed to forward message")
}
}}
@@ -38,7 +38,7 @@ export default function DashboardPage() {
setStats(data)
}
} catch {
// ignore
console.warn("Failed to fetch dashboard stats")
}
}
@@ -3,6 +3,7 @@
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
import { ErrorBoundary } from "@/components/shared/error-boundary"
import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
@@ -29,7 +30,9 @@ export default function DashboardLayout({
return (
<UserProvider>
<NotificationProvider>
<ErrorBoundary>
<DashboardContent>{children}</DashboardContent>
</ErrorBoundary>
</NotificationProvider>
</UserProvider>
)
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import Link from "next/link"
import { motion } from "framer-motion"
import { use } from "react"
@@ -26,22 +26,26 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
const [leadNotes, setLeadNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
const fetchNotes = useCallback(async () => {
const notesRes = await fetch(`/api/leads/${id}/notes`)
if (notesRes.ok) setLeadNotes(await notesRes.json())
}, [id])
useEffect(() => {
async function fetchData() {
try {
const [leadRes, notesRes] = await Promise.all([
const [leadRes] = await Promise.all([
fetch(`/api/leads/${id}`),
fetch(`/api/leads/${id}/notes`),
fetchNotes(),
])
if (leadRes.ok) setLead(await leadRes.json())
if (notesRes.ok) setLeadNotes(await notesRes.json())
} catch {
// ignore
console.warn("Failed to fetch lead details")
}
setLoading(false)
}
fetchData()
}, [id])
}, [id, fetchNotes])
if (loading) {
return (
@@ -89,8 +93,19 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
>
<Select
value={lead.status}
onValueChange={(v) => {
onValueChange={async (v) => {
const previousStatus = lead.status
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
try {
const res = await fetch(`/api/leads/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: v }),
})
if (!res.ok) setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
} catch {
setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
}
}}
>
<SelectTrigger className="h-9 w-[140px]">
@@ -125,7 +140,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<NoteForm leadId={lead.id} />
<NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
</motion.div>
<motion.div
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useForm } from "react-hook-form"
@@ -26,9 +26,7 @@ import {
} from "@/components/ui/select"
import { Card, CardContent } from "@/components/ui/card"
import { ArrowLeft } from "lucide-react"
import { Lead, LeadStatus } from "@/types"
import { leads } from "@/data/leads"
import { users } from "@/data/users"
import { User } from "@/types"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
@@ -49,6 +47,14 @@ export default function CreateLeadPage() {
const router = useRouter()
const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false)
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
}, [])
const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema),
@@ -64,35 +70,27 @@ export default function CreateLeadPage() {
},
})
function onSubmit(values: LeadFormValues) {
async function onSubmit(values: LeadFormValues) {
setSaving(true)
const now = new Date().toISOString()
const assignedUserId: string | null = values.assignedUserId === "none" ? null : (values.assignedUserId ?? null)
const assignedUser = assignedUserId ? users.find((u) => u.id === assignedUserId) ?? null : null
const newLead: Lead = {
id: `lead-${String(leads.length + 1).padStart(3, "0")}`,
companyName: values.companyName,
contactName: values.contactName,
email: values.email,
phone: values.phone ?? "",
source: values.source ?? "",
description: values.description ?? "",
status: values.status as LeadStatus,
assignedUserId,
assignedUser,
createdAt: now,
updatedAt: now,
try {
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
}
leads.unshift(newLead)
addNotification(
"lead_created",
"New Lead Created",
`${newLead.companyName}${newLead.contactName}`,
`/leads/${newLead.id}`
)
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to create lead")
const data = await res.json()
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
router.push("/leads")
} catch (e) {
console.error("Create lead error:", e)
} finally {
setSaving(false)
}
}
return (
@@ -39,6 +39,7 @@ export default function ProfilePage() {
toast.error("Failed to save avatar")
}
} catch {
console.warn("Failed to save avatar")
toast.error("Failed to save avatar")
}
}
@@ -25,7 +25,7 @@ export default function UsersPage() {
setUsers(data.users || [])
}
} catch {
// ignore
console.warn("Failed to fetch users in users page")
} finally {
setLoading(false)
}
+5 -1
View File
@@ -16,7 +16,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
const response = await chatWithAI(message, user.id, user.role)
// Forward the JWT from the session cookie to the Rust backend
const sessionCookie = request.cookies.get("session")?.value
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 })
const response = await chatWithAI(message, sessionCookie)
return NextResponse.json({ response })
} catch (error) {
@@ -14,6 +14,7 @@ export async function GET() {
const jobs = await fetchJobs()
return NextResponse.json({ jobs })
} catch {
console.warn("Failed to fetch AI jobs in API route")
return NextResponse.json({ jobs: [] })
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable. Please ensure PostgreSQL is running and configured." },
{ error: "Authentication service unavailable." },
{ status: 503 }
)
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(
_request: NextRequest,
@@ -12,18 +13,40 @@ export async function GET(
const { id } = await params
const [msgResult, otherReadResult] = await Promise.all([
query(
`SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
const { searchParams } = new URL(_request.url)
const limit = parseInt(searchParams.get("limit") || "100", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const before = searchParams.get("before") || ""
// Verify user is a participant
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id]
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
let msgSql = `SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
u.first_name || ' ' || u.last_name AS sender_name,
u.email AS sender_email,
u.avatar_url AS sender_avatar_url
FROM messages m
JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL
ORDER BY m.created_at ASC`,
[id],
),
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
const msgParams: any[] = [id]
if (before) {
msgSql += ` AND m.created_at < $2`
msgParams.push(before)
}
msgSql += ` ORDER BY m.created_at ASC`
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
msgParams.push(limit, offset)
const [msgResult, otherReadResult] = await Promise.all([
query(msgSql, msgParams),
query(
`SELECT last_read_at FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
@@ -40,7 +63,7 @@ export async function GET(
conversationId: id,
senderId: row.sender_id,
senderName: row.sender_name,
senderAvatar: row.sender_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.sender_name)}&background=1d4ed8&color=fff&size=128`,
senderAvatar: avatarSvgUrl(row.sender_name),
content: row.content,
timestamp: formatTime(new Date(row.created_at)),
createdAt: row.created_at,
@@ -65,8 +88,17 @@ export async function POST(
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { content } = await request.json()
// Verify user is a participant
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id]
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
const { content } = await request.json()
if (!content?.trim()) {
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
}
@@ -84,13 +116,28 @@ export async function POST(
)
const msg = result.rows[0]
const senderName = `${user.firstName} ${user.lastName}`
const otherResult = await query(
`SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
[id, user.id],
)
for (const row of otherResult.rows) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, 'chat_message', 'New Message', $2, '/chats', $3, 'conversation')`,
[row.user_id, `${senderName} sent a message`, id],
)
}
return NextResponse.json({
message: {
id: msg.id,
conversationId: id,
senderId: user.id,
senderName: `${user.firstName} ${user.lastName}`,
senderName,
senderAvatar: user.avatar,
content: content.trim(),
timestamp: formatTime(new Date(msg.created_at)),
@@ -19,6 +19,12 @@ export async function POST(
[id, user.id],
)
await query(
`UPDATE notifications SET is_read = TRUE
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
[user.id, id],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Mark read error:", error)
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() {
try {
@@ -26,7 +27,8 @@ export async function GET() {
WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
)
ORDER BY c.updated_at DESC`,
ORDER BY c.updated_at DESC
LIMIT 50`,
[user.id],
)
@@ -37,7 +39,7 @@ export async function GET() {
id: row.other_user_id,
name: row.other_user_name,
email: row.other_user_email,
avatar: row.other_user_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.other_user_name)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(row.other_user_name),
},
lastMessage: row.last_message || "",
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "",
@@ -103,7 +105,7 @@ export async function POST(request: NextRequest) {
id: other.id,
name: other.name,
email: other.email,
avatar: other.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(other.name)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(other.name),
},
lastMessage: "",
lastMessageTime: "",
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function getPeriodDateRange(period: string): { start: Date; end: Date } {
const end = new Date()
@@ -158,7 +159,7 @@ export async function GET(request: NextRequest) {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
@@ -32,6 +33,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const result = await query(
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
@@ -39,8 +43,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
FROM customer_notes cn
JOIN users u ON u.id = cn.author_id
WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL
ORDER BY cn.created_at DESC`,
[id]
ORDER BY cn.created_at DESC
LIMIT $2 OFFSET $3`,
[id, limit, offset]
)
const notes = result.rows.map((r: any) => ({
@@ -48,7 +53,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
leadId: id,
userId: r.user_id,
authorName: `${r.first_name} ${r.last_name}`,
authorAvatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
authorAvatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
note: r.content,
createdAt: r.created_at,
updatedAt: r.updated_at,
+88 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function stageToStatus(name: string): string {
switch (name) {
@@ -22,6 +23,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
@@ -31,8 +33,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.id = $1 AND l.deleted_at IS NULL`,
[id]
WHERE l.id = $1 AND l.deleted_at IS NULL
AND ($2 = true OR l.assigned_to = $3)`,
[id, isAdmin, user.id]
)
if (result.rows.length === 0) {
@@ -54,7 +57,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
@@ -66,3 +69,85 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 })
}
}
function statusToStageName(status: string): string {
switch (status) {
case "open": return "New"
case "contacted": return "Contacted"
case "pending": return "Qualified"
case "closed": return "Closed Won"
case "ignored": return "Closed Lost"
default: return "New"
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Verify access
const accessCheck = await query(
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
AND ($2 = true OR assigned_to = $3)`,
[id, isAdmin, user.id]
)
if (accessCheck.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.companyName !== undefined) { fields.push(`company_name = $${idx++}`); values.push(body.companyName) }
if (body.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) }
if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) }
if (body.phone !== undefined) { fields.push(`phone = $${idx++}`); values.push(body.phone) }
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
if (body.assignedUserId !== undefined) {
fields.push(`assigned_to = $${idx++}`)
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
}
if (body.status !== undefined) {
const stageName = statusToStageName(body.status)
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
if (stageResult.rows.length > 0) {
fields.push(`stage_id = $${idx++}`)
values.push(stageResult.rows[0].id)
}
}
if (body.score !== undefined) {
const score = Number(body.score)
if (!isFinite(score) || score < 0 || score > 100) {
return NextResponse.json({ error: "Score must be a number between 0 and 100" }, { status: 400 })
}
fields.push(`score = $${idx++}`)
values.push(score)
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
const sql = `UPDATE leads SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING id`
const result = await query(sql, values)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
return NextResponse.json({ success: true, id: result.rows[0].id })
} catch (error) {
console.error("Lead PATCH error:", error)
return NextResponse.json({ error: "Failed to update lead" }, { status: 500 })
}
}
+77 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function stageToStatus(name: string): string {
switch (name) {
@@ -38,6 +39,9 @@ export async function GET(request: NextRequest) {
const search = searchParams.get("search") || ""
const status = searchParams.get("status") || "all"
const period = searchParams.get("period") || "all"
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const isAdmin = user.role === "admin" || user.role === "super_admin"
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
@@ -66,7 +70,16 @@ export async function GET(request: NextRequest) {
paramIdx++
}
if (!isAdmin) {
sql += ` AND l.assigned_to = $${paramIdx}`
params.push(user.id)
paramIdx++
}
sql += ` ORDER BY l.created_at DESC`
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
params.push(limit, offset)
paramIdx += 2
const result = await query(sql, params)
@@ -86,7 +99,7 @@ export async function GET(request: NextRequest) {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
@@ -103,3 +116,66 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const stageResult = await query(
"SELECT id FROM lead_stages WHERE name = $1",
[body.status === "open" ? "New" : "Contacted"]
)
const stageId = stageResult.rows[0]?.id || 1
const result = await query(
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING id`,
[
body.companyName,
body.contactName,
body.email,
body.phone || null,
body.description || null,
body.source || null,
stageId,
body.assignedUserId === "none" ? null : body.assignedUserId,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Leads POST error:", error)
return NextResponse.json({ error: "Failed to create lead" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
const result = await query(
"UPDATE leads SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3) RETURNING id",
[id, isAdmin, user.id]
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Leads DELETE error:", error)
return NextResponse.json({ error: "Failed to delete lead" }, { status: 500 })
}
}
@@ -8,7 +8,7 @@ export async function GET() {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT id, type, title, description, link, is_read, created_at
`SELECT id, type, title, description, link, is_read, created_at, context_id, context_type
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
@@ -24,6 +24,8 @@ export async function GET() {
link: r.link,
read: r.is_read,
timestamp: r.created_at,
contextId: r.context_id,
contextType: r.context_type,
}))
const unreadResult = await query(
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const result = await query(`SELECT * FROM company_settings ORDER BY updated_at DESC LIMIT 1`)
const row = result.rows[0]
if (!row) {
return NextResponse.json({
companyName: "Coastal IT Solutions",
companyEmail: "info@coastalit.com",
companyPhone: "(555) 123-4567",
companyWebsite: "https://coastalit.com",
companyAddress: "123 Business Ave, Suite 100, San Francisco, CA 94105",
})
}
return NextResponse.json({
companyName: row.company_name || "",
companyEmail: row.company_email || "",
companyPhone: row.company_phone || "",
companyWebsite: row.company_website || "",
companyAddress: row.company_address || "",
})
} catch (error) {
console.error("Company settings GET error:", error)
return NextResponse.json({ error: "Failed to load company settings" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const body = await request.json()
await query(
`UPDATE company_settings SET
company_name = $1, company_email = $2, company_phone = $3,
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`,
[
body.companyName || "",
body.companyEmail || "",
body.companyPhone || "",
body.companyWebsite || "",
body.companyAddress || "",
user.id,
],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Company settings PATCH error:", error)
return NextResponse.json({ error: "Failed to save company settings" }, { status: 500 })
}
}
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT timezone, date_format, items_per_page FROM user_preferences WHERE user_id = $1`,
[user.id],
)
if (result.rowCount === 0) {
return NextResponse.json({
timezone: "america-los_angeles",
dateFormat: "mdy",
itemsPerPage: 20,
})
}
const r = result.rows[0]
return NextResponse.json({
timezone: r.timezone,
dateFormat: r.date_format,
itemsPerPage: r.items_per_page,
})
} catch (error) {
console.error("Preferences GET error:", error)
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
await query(
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id)
DO UPDATE SET timezone = $2, date_format = $3, items_per_page = $4, updated_at = NOW()`,
[
user.id,
body.timezone || "america-los_angeles",
body.dateFormat || "mdy",
body.itemsPerPage || 20,
],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Preferences PATCH error:", error)
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
}
}
@@ -1,10 +1,14 @@
import { NextResponse } from "next/server"
import os from "os"
import { getSessionUser } from "@/lib/auth"
let prevCpu = process.cpuUsage()
let prevTime = Date.now()
export async function GET() {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const now = Date.now()
const elapsed = now - prevTime
const currentCpu = process.cpuUsage()
@@ -8,9 +8,16 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super admins can delete users" }, { status: 403 })
}
const { id } = await params
if (id === sessionUser.id) {
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
}
await query(
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
[id]
+22 -4
View File
@@ -1,9 +1,17 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() {
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const result = await query(
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active AS active, u.created_at, u.avatar_url,
@@ -12,7 +20,9 @@ export async function GET() {
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.deleted_at IS NULL
ORDER BY u.created_at DESC`
ORDER BY u.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
)
const users = result.rows.map((row: any) => ({
id: row.id,
@@ -20,7 +30,7 @@ export async function GET() {
email: row.email,
role: row.role.toLowerCase(),
active: row.active,
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${row.first_name}+${row.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${row.first_name} ${row.last_name}`),
createdAt: row.created_at,
}))
return NextResponse.json({ users }, { status: 200 })
@@ -36,12 +46,20 @@ export async function POST(request: NextRequest) {
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super admins can create users" }, { status: 403 })
}
const { name, email, password, role, active } = await request.json()
if (!name || !email || !password || !role) {
return NextResponse.json({ error: "Name, email, password, and role are required" }, { status: 400 })
}
const validRoles = ["sales", "admin", "super_admin", "dev"]
if (!validRoles.includes(role)) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
}
const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName
@@ -52,7 +70,7 @@ export async function POST(request: NextRequest) {
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active, sessionUser.id]
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
)
const roleId = (
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) {
try {
@@ -29,7 +30,7 @@ export async function GET(request: NextRequest) {
id: row.id,
name: row.name,
email: row.email,
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.name)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(row.name),
}))
return NextResponse.json({ users })
+259 -7
View File
@@ -22,14 +22,14 @@
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 217.2 91.2% 59.8%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 221.2 83.2% 53.3%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 224.3 76.3% 48%;
--sidebar-accent: 210 40% 96.1%;
--sidebar-accent-foreground: 222.2 84% 4.9%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
@@ -63,6 +63,258 @@
--sidebar-ring: 224.3 76.3% 48%;
}
.ocean {
--primary: 187 75% 42%;
--primary-foreground: 210 40% 98%;
--ring: 187 75% 42%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 187 75% 42%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 187 30% 94%;
--sidebar-accent-foreground: 187 50% 20%;
--sidebar-border: 187 20% 88%;
--sidebar-ring: 187 75% 42%;
}
.dark.ocean {
--primary: 187 75% 50%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 187 75% 50%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 187 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 187 75% 50%;
}
.forest {
--primary: 142 76% 36%;
--primary-foreground: 210 40% 98%;
--ring: 142 76% 36%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 142 76% 36%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 142 30% 94%;
--sidebar-accent-foreground: 142 50% 20%;
--sidebar-border: 142 20% 88%;
--sidebar-ring: 142 76% 36%;
}
.dark.forest {
--primary: 142 76% 44%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 142 76% 44%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 142 76% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 142 76% 44%;
}
.sunset {
--primary: 24 95% 53%;
--primary-foreground: 210 40% 98%;
--ring: 24 95% 53%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 24 95% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 24 30% 94%;
--sidebar-accent-foreground: 24 50% 20%;
--sidebar-border: 24 20% 88%;
--sidebar-ring: 24 95% 53%;
}
.dark.sunset {
--primary: 24 95% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 24 95% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 24 95% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 24 95% 58%;
}
.midnight {
--primary: 230 75% 55%;
--primary-foreground: 210 40% 98%;
--ring: 230 75% 55%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 230 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 230 30% 94%;
--sidebar-accent-foreground: 230 50% 20%;
--sidebar-border: 230 20% 88%;
--sidebar-ring: 230 75% 55%;
}
.dark.midnight {
--primary: 230 75% 62%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 230 75% 62%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 230 75% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 230 75% 62%;
}
.rose {
--primary: 346 77% 50%;
--primary-foreground: 210 40% 98%;
--ring: 346 77% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 346 77% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 346 30% 94%;
--sidebar-accent-foreground: 346 50% 20%;
--sidebar-border: 346 20% 88%;
--sidebar-ring: 346 77% 50%;
}
.dark.rose {
--primary: 346 77% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 346 77% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 346 77% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 346 77% 58%;
}
.amber {
--primary: 38 92% 50%;
--primary-foreground: 210 40% 98%;
--ring: 38 92% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 38 92% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 38 30% 94%;
--sidebar-accent-foreground: 38 50% 20%;
--sidebar-border: 38 20% 88%;
--sidebar-ring: 38 92% 50%;
}
.dark.amber {
--primary: 38 92% 56%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 38 92% 56%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 38 92% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 38 92% 56%;
}
.violet {
--primary: 262 83% 58%;
--primary-foreground: 210 40% 98%;
--ring: 262 83% 58%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 262 83% 58%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 262 30% 94%;
--sidebar-accent-foreground: 262 50% 20%;
--sidebar-border: 262 20% 88%;
--sidebar-ring: 262 83% 58%;
}
.dark.violet {
--primary: 262 83% 65%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 262 83% 65%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 262 83% 68%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 262 83% 65%;
}
.slate {
--primary: 215 20% 45%;
--primary-foreground: 210 40% 98%;
--ring: 215 20% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 215 20% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 215 20% 94%;
--sidebar-accent-foreground: 215 50% 20%;
--sidebar-border: 215 20% 88%;
--sidebar-ring: 215 20% 45%;
}
.dark.slate {
--primary: 215 20% 60%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 215 20% 60%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 215 20% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 215 20% 60%;
}
.ruby {
--primary: 351 85% 45%;
--primary-foreground: 210 40% 98%;
--ring: 351 85% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 351 85% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 351 30% 94%;
--sidebar-accent-foreground: 351 50% 20%;
--sidebar-border: 351 20% 88%;
--sidebar-ring: 351 85% 45%;
}
.dark.ruby {
--primary: 351 85% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 351 85% 52%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 351 85% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 351 85% 52%;
}
@layer base {
* {
@apply border-border;
+1 -1
View File
@@ -340,7 +340,7 @@ export default function LoginPage() {
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
Password
</label>
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline">
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}>
Forgot password?
</button>
</div>
+16 -37
View File
@@ -1,8 +1,19 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { useState, useRef, useEffect, Fragment } from "react"
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
const parts = text.split(urlRegex)
return parts.map((part, i) => {
if (part.startsWith("http://") || part.startsWith("https://")) {
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a>
}
return <Fragment key={i}>{part}</Fragment>
})
}
interface ChatMessage {
role: "user" | "assistant"
content: string
@@ -21,11 +32,11 @@ export function AIChat() {
.then((r) => r.json())
.then((data) => {
if (data.jobs?.length) {
const jobNames = data.jobs.map((j: { job_title: string }) => j.job_title).join(", ")
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages([
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help you with tips for targeting: ${jobNames}. What would you like to know?`,
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => ` ${n}`).join("\n")}\n\nWhat would you like to know?`,
},
])
} else {
@@ -45,41 +56,9 @@ export function AIChat() {
},
])
})
fetch("/api/ai/jobs")
.then((r) => r.json())
.then((data) => {
if (data.jobs) {
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages([
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `${n}`).join("\n")}\n\nWhat would you like to know?`,
},
])
}
})
}, [])
useEffect(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
.then((data) => {
if (data.jobs?.length) {
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages((prev) =>
prev.length === 1 && prev[0].role === "assistant"
? [
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `${n}`).join("\n")}\n\nWhat would you like to know?`,
},
]
: prev,
)
}
})
}, [])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
@@ -169,7 +148,7 @@ export function AIChat() {
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
}`}
>
{msg.content}
{linkifyText(msg.content)}
</div>
{msg.role === "user" && (
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
@@ -37,7 +37,7 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
setChartData(data.leadsPerMonth || [])
}
} catch {
// ignore
console.warn("Failed to fetch year data in leads per month chart")
}
}
const thisYear = new Date().getFullYear()
@@ -18,7 +18,7 @@ export function SystemMonitor() {
setRssMB(data.rssMB)
setCpuPct(data.cpuPct)
} catch {
// ignore
console.warn("Failed to fetch system stats")
}
}
@@ -32,7 +32,6 @@ import {
} from "@/components/ui/select"
import { Lead, LeadStatus, User } from "@/types"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider"
const leadFormSchema = z.object({
@@ -52,11 +51,21 @@ interface LeadFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
lead?: Lead | null
onSuccess?: () => void
}
export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps) {
export function LeadFormDialog({ open, onOpenChange, lead, onSuccess }: LeadFormDialogProps) {
const isEditing = !!lead
const { addNotification } = useNotifications()
const [users, setUsers] = useState<User[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
}, [])
const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema),
@@ -98,22 +107,37 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
}
}, [lead, form])
function onSubmit(values: LeadFormValues) {
async function onSubmit(values: LeadFormValues) {
setSaving(true)
try {
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId,
}
if (!isEditing) {
const now = new Date().toISOString()
addNotification(
"lead_created",
"New Lead Created",
`${values.companyName}${values.contactName}`,
"#"
)
if (isEditing && lead) {
const res = await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to update")
addNotification("lead_status_changed", "Lead Updated", `${values.companyName}`, "#")
} else {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to create")
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, "#")
}
console.log(payload)
onOpenChange(false)
onSuccess?.()
} catch (e) {
console.error("Lead save error:", e)
} finally {
setSaving(false)
}
}
return (
@@ -277,7 +301,7 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit">{isEditing ? "Save Changes" : "Create Lead"}</Button>
<Button type="submit" disabled={saving}>{saving ? "Saving..." : isEditing ? "Save Changes" : "Create Lead"}</Button>
</DialogFooter>
</form>
</Form>
@@ -6,12 +6,15 @@ import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Send } from "lucide-react"
import { useUser } from "@/providers/user-provider"
interface NoteFormProps {
leadId: string
onNoteAdded?: () => void
}
export function NoteForm({ leadId }: NoteFormProps) {
export function NoteForm({ leadId, onNoteAdded }: NoteFormProps) {
const { user } = useUser()
const [note, setNote] = useState("")
const [submitting, setSubmitting] = useState(false)
@@ -25,34 +28,43 @@ export function NoteForm({ leadId }: NoteFormProps) {
body: JSON.stringify({ content: note }),
})
setNote("")
window.location.reload()
onNoteAdded?.()
} catch {
// ignore
console.warn("Failed to add note")
}
setSubmitting(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
return (
<Card>
<CardHeader>
<CardTitle>Add Note</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div className="flex gap-4">
<Avatar className="h-10 w-10 shrink-0">
<AvatarImage src="https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=64" />
<AvatarFallback>SC</AvatarFallback>
<AvatarImage src={user?.avatar || undefined} />
<AvatarFallback>{user?.name?.charAt(0) || "U"}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-3">
<Textarea
placeholder="Write a note about this lead..."
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleKeyDown}
className="min-h-[100px] resize-none"
/>
<div className="flex justify-end">
<Button
onClick={handleSubmit}
type="submit"
disabled={!note.trim() || submitting}
className="gap-2"
>
@@ -62,6 +74,7 @@ export function NoteForm({ leadId }: NoteFormProps) {
</div>
</div>
</div>
</form>
</CardContent>
</Card>
)
@@ -1,13 +1,73 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { COMPANY_NAME } from "@/lib/constants"
import { toast } from "sonner"
interface CompanyData {
companyName: string
companyEmail: string
companyPhone: string
companyWebsite: string
companyAddress: string
}
export function CompanySettingsForm() {
const [data, setData] = useState<CompanyData>({
companyName: "",
companyEmail: "",
companyPhone: "",
companyWebsite: "",
companyAddress: "",
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
async function load() {
try {
const res = await fetch("/api/settings/company")
if (res.ok) {
const json = await res.json()
setData(json)
}
} catch {
console.warn("Failed to load company settings")
} finally {
setLoading(false)
}
}
load()
}, [])
async function handleSave() {
setSaving(true)
try {
const res = await fetch("/api/settings/company", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (res.ok) {
toast.success("Company settings saved")
} else {
toast.error("Failed to save company settings")
}
} catch {
console.warn("Failed to save company settings")
toast.error("Failed to save company settings")
} finally {
setSaving(false)
}
}
function update(field: keyof CompanyData, value: string) {
setData((prev) => ({ ...prev, [field]: value }))
}
return (
<Card>
<CardHeader>
@@ -20,28 +80,32 @@ export function CompanySettingsForm() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="company-name">Company Name</Label>
<Input id="company-name" defaultValue={COMPANY_NAME} />
<Input id="company-name" disabled={loading} value={data.companyName} onChange={(e) => update("companyName", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="company-email">Company Email</Label>
<Input id="company-email" type="email" defaultValue="info@coastalit.com" />
<Input id="company-email" type="email" disabled={loading} value={data.companyEmail} onChange={(e) => update("companyEmail", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="company-phone">Phone</Label>
<Input id="company-phone" defaultValue="(555) 123-4567" />
<Input id="company-phone" disabled={loading} value={data.companyPhone} onChange={(e) => update("companyPhone", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="company-website">Website</Label>
<Input id="company-website" defaultValue="https://coastalit.com" />
<Input id="company-website" disabled={loading} value={data.companyWebsite} onChange={(e) => update("companyWebsite", e.target.value)} />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="company-address">Address</Label>
<Input id="company-address" defaultValue="123 Business Ave, Suite 100, San Francisco, CA 94105" />
<Input id="company-address" disabled={loading} value={data.companyAddress} onChange={(e) => update("companyAddress", e.target.value)} />
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="flex justify-end pt-4">
<Button onClick={() => toast.success("Company settings saved")}>Save Changes</Button>
<Button type="submit" disabled={loading || saving}>
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
@@ -45,7 +45,7 @@ export function NotificationSettings() {
setPrefs(data)
}
} catch {
// ignore
console.warn("Failed to load notification preferences")
} finally {
setLoading(false)
}
@@ -67,6 +67,7 @@ export function NotificationSettings() {
toast.error("Failed to save preferences")
}
} catch {
console.warn("Failed to save notification preferences")
toast.error("Failed to save preferences")
} finally {
setSaving(false)
@@ -105,11 +106,13 @@ export function NotificationSettings() {
/>
</div>
))}
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="flex justify-end pt-4">
<Button onClick={handleSave} disabled={loading || saving}>
<Button type="submit" disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
@@ -1,5 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
@@ -7,30 +8,76 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils"
import { Sun, Moon, Monitor } from "lucide-react"
export function ThemeSettings() {
const { theme, setTheme } = useTheme()
const COLOR_THEME_KEY = "crm-color-theme"
const options = [
const modeOptions = [
{ value: "light", icon: Sun, label: "Light" },
{ value: "dark", icon: Moon, label: "Dark" },
{ value: "system", icon: Monitor, label: "System" },
]
]
const themeOptions = [
{ value: "default", label: "Default", color: "bg-blue-600", ring: "ring-blue-600" },
{ value: "ocean", label: "Ocean", color: "bg-teal-500", ring: "ring-teal-500" },
{ value: "forest", label: "Forest", color: "bg-green-600", ring: "ring-green-600" },
{ value: "sunset", label: "Sunset", color: "bg-orange-500", ring: "ring-orange-500" },
{ value: "midnight", label: "Midnight", color: "bg-indigo-600", ring: "ring-indigo-600" },
{ value: "rose", label: "Rose", color: "bg-pink-600", ring: "ring-pink-600" },
{ value: "amber", label: "Amber", color: "bg-amber-500", ring: "ring-amber-500" },
{ value: "violet", label: "Violet", color: "bg-violet-600", ring: "ring-violet-600" },
{ value: "slate", label: "Slate", color: "bg-slate-500", ring: "ring-slate-500" },
{ value: "ruby", label: "Ruby", color: "bg-red-600", ring: "ring-red-600" },
]
function getStoredColorTheme(): string {
if (typeof window === "undefined") return "default"
return localStorage.getItem(COLOR_THEME_KEY) || "default"
}
function applyColorTheme(theme: string) {
document.documentElement.className = document.documentElement.className
.replace(/\b(default|ocean|forest|sunset|midnight|rose|amber|violet|slate|ruby)\b/g, "")
.trim()
if (theme !== "default") {
document.documentElement.classList.add(theme)
}
localStorage.setItem(COLOR_THEME_KEY, theme)
}
export function ThemeSettings() {
const { theme, setTheme } = useTheme()
const [colorTheme, setColorTheme] = useState("default")
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
setColorTheme(getStoredColorTheme())
applyColorTheme(getStoredColorTheme())
}, [])
function handleColorChange(value: string) {
setColorTheme(value)
applyColorTheme(value)
}
if (!mounted) return null
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Theme Settings</CardTitle>
<CardTitle>Theme Mode</CardTitle>
<CardDescription>
Choose your preferred appearance for the dashboard.
Choose your preferred appearance mode.
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup value={theme} onValueChange={setTheme} className="grid grid-cols-3 gap-4">
{options.map(({ value, icon: Icon, label }) => (
<RadioGroup value={theme || "dark"} onValueChange={setTheme} className="grid grid-cols-3 gap-4">
{modeOptions.map(({ value, icon: Icon, label }) => (
<div key={value}>
<RadioGroupItem value={value} id={value} className="peer sr-only" />
<RadioGroupItem value={value} id={`mode-${value}`} className="peer sr-only" />
<Label
htmlFor={value}
htmlFor={`mode-${value}`}
className={cn(
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
@@ -44,5 +91,34 @@ export function ThemeSettings() {
</RadioGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Color Theme</CardTitle>
<CardDescription>
Select a color accent for the dashboard.
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup value={colorTheme} onValueChange={handleColorChange} className="grid grid-cols-5 gap-4">
{themeOptions.map(({ value, label, color, ring }) => (
<div key={value}>
<RadioGroupItem value={value} id={`color-${value}`} className="peer sr-only" />
<Label
htmlFor={`color-${value}`}
className={cn(
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)}
>
<div className={cn("h-8 w-8 rounded-full", color, ring, "ring-2 ring-offset-2 ring-offset-background")} />
<span className="text-sm font-medium">{label}</span>
</Label>
</div>
))}
</RadioGroup>
</CardContent>
</Card>
</div>
)
}
@@ -1,5 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
@@ -12,7 +13,59 @@ import {
SelectValue,
} from "@/components/ui/select"
interface Preferences {
timezone: string
dateFormat: string
itemsPerPage: number
}
export function UserPreferencesForm() {
const [prefs, setPrefs] = useState<Preferences>({
timezone: "america-los_angeles",
dateFormat: "mdy",
itemsPerPage: 20,
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
async function load() {
try {
const res = await fetch("/api/settings/preferences")
if (res.ok) {
const json = await res.json()
setPrefs(json)
}
} catch {
console.warn("Failed to load user preferences")
} finally {
setLoading(false)
}
}
load()
}, [])
async function handleSave() {
setSaving(true)
try {
const res = await fetch("/api/settings/preferences", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(prefs),
})
if (res.ok) {
toast.success("Preferences saved")
} else {
toast.error("Failed to save preferences")
}
} catch {
console.warn("Failed to save user preferences")
toast.error("Failed to save preferences")
} finally {
setSaving(false)
}
}
return (
<Card>
<CardHeader>
@@ -25,7 +78,7 @@ export function UserPreferencesForm() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Select defaultValue="america-los_angeles">
<Select disabled={loading} value={prefs.timezone} onValueChange={(v) => setPrefs((p) => ({ ...p, timezone: v }))}>
<SelectTrigger id="timezone">
<SelectValue />
</SelectTrigger>
@@ -39,7 +92,7 @@ export function UserPreferencesForm() {
</div>
<div className="space-y-2">
<Label htmlFor="date-format">Date Format</Label>
<Select defaultValue="mdy">
<Select disabled={loading} value={prefs.dateFormat} onValueChange={(v) => setPrefs((p) => ({ ...p, dateFormat: v }))}>
<SelectTrigger id="date-format">
<SelectValue />
</SelectTrigger>
@@ -52,7 +105,7 @@ export function UserPreferencesForm() {
</div>
<div className="space-y-2">
<Label htmlFor="items-per-page">Items Per Page</Label>
<Select defaultValue="20">
<Select disabled={loading} value={String(prefs.itemsPerPage)} onValueChange={(v) => setPrefs((p) => ({ ...p, itemsPerPage: parseInt(v) }))}>
<SelectTrigger id="items-per-page">
<SelectValue />
</SelectTrigger>
@@ -65,9 +118,13 @@ export function UserPreferencesForm() {
</Select>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="flex justify-end pt-4">
<Button onClick={() => toast.success("Preferences saved")}>Save Preferences</Button>
<Button type="submit" disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
@@ -0,0 +1,50 @@
"use client"
import { Component, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: { componentStack?: string }) {
console.error("ErrorBoundary caught:", error, info.componentStack)
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<h2 className="text-xl font-bold">Something went wrong</h2>
<p className="text-muted-foreground text-sm max-w-md text-center">
{this.state.error?.message || "An unexpected error occurred"}
</p>
<Button variant="outline" onClick={() => {
this.setState({ hasError: false, error: undefined })
window.location.reload()
}}>
Reload Page
</Button>
</div>
)
}
return this.props.children
}
}
@@ -36,7 +36,7 @@ import { toast } from "sonner";
const createSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
password: z.string().min(8, "Password must be at least 8 characters"),
role: z.string().min(1, "Role is required"),
active: z.boolean(),
});
@@ -32,6 +32,7 @@ export function UsersTable({ data, loading, onUserDeleted }: UsersTableProps) {
toast.success("User deleted")
onUserDeleted?.()
} catch {
console.warn("Failed to delete user")
toast.error("Failed to delete user")
}
}
+5 -23
View File
@@ -1,10 +1,10 @@
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
export async function chatWithAI(message: string, userId: string, userRole: string) {
export async function chatWithAI(message: string, jwtToken: string) {
const res = await fetch(`${AI_SERVICE}/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, user_id: userId, user_role: userRole }),
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message }),
})
if (!res.ok) {
@@ -23,30 +23,11 @@ export async function fetchJobs() {
const data = await res.json()
return data.jobs || []
} catch {
console.warn("Failed to fetch AI jobs")
return []
}
}
export async function getInstructions() {
try {
const res = await fetch(`${AI_SERVICE}/ai/instructions`)
if (!res.ok) return null
const data = await res.json()
return data.success ? data.instructions : null
} catch {
return null
}
}
export async function updateInstructions(entry: string, content?: string) {
const res = await fetch(`${AI_SERVICE}/ai/instructions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry, content }),
})
return res.ok
}
export async function checkAiServiceStatus() {
try {
const res = await fetch(`${AI_SERVICE}/health`)
@@ -54,6 +35,7 @@ export async function checkAiServiceStatus() {
const data = await res.json()
return data.status === "ok"
} catch {
console.warn("Failed to check AI service status")
return false
}
}
+9 -9
View File
@@ -3,9 +3,11 @@ import bcrypt from "bcryptjs";
import { query } from "@/lib/db";
import { cookies } from "next/headers";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production",
);
const RAW_SECRET = process.env.JWT_SECRET;
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5;
@@ -174,7 +176,7 @@ export function mapDbUserToSessionUser(
SUPER_ADMIN: "super_admin",
ADMIN: "admin",
SALES_USER: "sales",
DEVELOPER: "sales",
DEVELOPER: "dev",
};
const avatarUrl = dbUser.avatar_url as string | null
@@ -186,9 +188,7 @@ export function mapDbUserToSessionUser(
firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(),
avatar: avatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(
`${dbUser.first_name}+${dbUser.last_name}`,
)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarUrl || (() => { const f = ((dbUser.first_name as string)?.[0] || '').toUpperCase(); const l = ((dbUser.last_name as string)?.[0] || '').toUpperCase(); return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><rect width="128" height="128" fill="#1d4ed8"/><text x="64" y="80" font-size="48" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${f}${l}</text></svg>`)}` })(),
};
}
@@ -199,7 +199,7 @@ export async function createSession(userId: string, role: string) {
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});
@@ -212,7 +212,7 @@ export async function destroySession() {
cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "strict",
path: "/",
maxAge: 0,
});
+26
View File
@@ -0,0 +1,26 @@
function sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9\s\-'.-]/g, "").slice(0, 50)
}
export function getInitials(name: string): string {
return sanitizeName(name)
.split(/\s+/)
.filter(Boolean)
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
export function avatarSvgUrl(name: string, bg = "1d4ed8", size = 128): string {
const initials = getInitials(name)
const fontSize = Math.round(size * 0.375)
const y = Math.round(size * 0.625)
return `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" fill="#${bg}"/><text x="${Math.round(size / 2)}" y="${y}" font-size="${fontSize}" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${initials}</text></svg>`
)}`
}
export function getAvatarUrl(name: string, avatarUrl: string | null | undefined, bg = "1d4ed8", size = 128): string {
return avatarUrl || avatarSvgUrl(name, bg, size)
}
+6 -1
View File
@@ -1,10 +1,15 @@
import { Pool } from "pg"
const dbUrl = process.env.DATABASE_URL
if (!dbUrl) {
throw new Error("DATABASE_URL environment variable is required")
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
connectionString: dbUrl,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
})
pool.on("error", (err) => {
+11 -8
View File
@@ -2,15 +2,16 @@ import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { jwtVerify } from "jose"
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
)
const RAW_SECRET = process.env.JWT_SECRET
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required")
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [
"/login",
"/api/auth/login",
"/api/auth/logout",
"/api/system",
"/_next/static",
"/_next/image",
"/favicon.ico",
@@ -25,10 +26,6 @@ export async function middleware(request: NextRequest) {
(route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route)
)
if (pathname === "/api/auth/me") {
return NextResponse.next()
}
if (isPublic) {
return NextResponse.next()
}
@@ -36,6 +33,9 @@ export async function middleware(request: NextRequest) {
const sessionCookie = request.cookies.get("session")?.value
if (!sessionCookie) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
@@ -45,6 +45,9 @@ export async function middleware(request: NextRequest) {
await jwtVerify(sessionCookie, JWT_SECRET)
return NextResponse.next()
} catch {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
@@ -30,7 +30,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
setNotifications(data.notifications)
}
} catch {
// ignore
console.warn("Failed to fetch notifications in notification provider")
} finally {
setLoading(false)
}
@@ -51,7 +51,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
setUnreadChatCount(total)
}
} catch {
// ignore
console.warn("Failed to fetch unread conversations in notification provider")
}
}
fetchUnread()
@@ -83,7 +83,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
body: JSON.stringify({ type, title, description, link }),
})
} catch {
// ignore
console.warn("Failed to add notification in notification provider")
}
},
[]
@@ -97,7 +97,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try {
await fetch(`/api/notifications/${id}`, { method: "PATCH" })
} catch {
// ignore
console.warn("Failed to mark notification as read in notification provider")
}
}, [])
@@ -106,7 +106,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try {
await fetch("/api/notifications", { method: "PATCH" })
} catch {
// ignore
console.warn("Failed to mark all notifications as read in notification provider")
}
}, [])
@@ -116,7 +116,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try {
await fetch(`/api/notifications/${id}`, { method: "DELETE" })
} catch {
// ignore
console.warn("Failed to dismiss notification in notification provider")
}
}, [])
@@ -4,5 +4,12 @@ import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
return (
<NextThemesProvider
themes={["light", "dark", "system", "ocean", "forest", "sunset", "midnight"]}
{...props}
>
{children}
</NextThemesProvider>
)
}
+2 -1
View File
@@ -39,6 +39,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
setUser(null)
}
} catch {
console.warn("Failed to fetch user in user provider")
setUser(null)
} finally {
setLoading(false)
@@ -53,7 +54,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
try {
await fetch("/api/auth/logout", { method: "POST" })
} catch {
// Proceed with client-side logout even if API fails
console.warn("Failed to logout in user provider")
}
setUser(null)
router.push("/login")