Compare commits

...

17 Commits

Author SHA1 Message Date
Hannah_Bagga c9c855579b Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Waiting to run
2026-07-10 14:44:01 +02:00
Hannah_Bagga 3a348e3616 Dark green hover fixed 2026-07-10 14:43:49 +02:00
Ace dba4c84cd5 Added finishing touch on other languages
Build & Auto-Repair / build (push) Has been cancelled
2026-07-08 14:24:03 +02:00
Ace d77ff2b965 Added logic to the 4 languages so it can search each language at a time
Build & Auto-Repair / build (push) Has been cancelled
2026-07-07 10:08:21 +02:00
Ace 0bc3ca58ed Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-07 10:03:38 +02:00
Ace d793604e92 Added 4 Languages English, Afrikaans, Xhosa, Zulu, tested but brought leads down to 3 leads only will see how to make it more without losing effiency of the model. 2026-07-07 10:03:35 +02:00
TroodonEnjoyer bc83af8e00 Started PostgreSQL — data directory was uninitialized, ran initdb, set password, switched to md5 auth, all 24 migrations applied.
Build & Auto-Repair / build (push) Has been cancelled
Hardened db.ts — added statement_timeout: 30000, idle_in_transaction_session_timeout: 10000, created transaction() helper (BEGIN/COMMIT/ROLLBACK).
Multi-step API routes wrapped in transactions — Events POST, Conversations POST use atomic blocks.
Fixed leads pagination — status filter pushed into SQL WHERE (no client-side filtering after LIMIT/OFFSET), added SELECT COUNT(*) for total.
Rewrote dashboard — SQL aggregations (COUNT + GROUP BY + date_trunc) replace loading all leads into memory.
Optimized conversations GET — merged duplicate correlated subqueries into single LEFT JOIN LATERAL.
Created migration 021_performance_indexes.sql — 8 missing indexes + set_session_user_context() function caching current_user_hierarchy_level() as session variable (avoids per-query RLS join).
Fixed build error — duplicate const other in conversations route. Also fixed a TypeScript never error in dashboard breakdown map.
Verified — npx tsc --noEmit clean, npm test (13 pass, 7 integration skip gracefully), npx next build succeeds.
2026-07-06 13:36:48 +02:00
Hannah_Bagga 80dee367e8 fixed theme setting
Build & Auto-Repair / build (push) Has been cancelled
2026-07-05 21:54:39 +02:00
Hannah_Bagga da99b695be just some things i needed to get back into the CRM
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 16:05:03 +02:00
caitlin dd6767980a search button theme fix in ai assistant
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 16:04:53 +02:00
Ace c1c4afadbb Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 13:26:58 +02:00
Ace 37679a7a60 Added Ai load fix 2026-07-02 13:26:56 +02:00
Hannah_Bagga c0cb715ced ai assistant page fixed
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 21:07:42 +02:00
Hannah_Bagga 1269f6cce8 Revert "Removed quick tips, recent prompts Messages today, Tokens used"
Build & Auto-Repair / build (push) Has been cancelled
This reverts commit 370f935cbb.
2026-07-01 14:36:47 +02:00
Hannah_Bagga 370f935cbb Removed quick tips, recent prompts Messages today, Tokens used
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 14:24:32 +02:00
Ace faf9dd551a Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:16:59 +02:00
Ace f67d9377a0 trying to fix AI chat 2026-07-01 13:16:57 +02:00
39 changed files with 3154 additions and 753 deletions
+29 -29
View File
@@ -12,6 +12,7 @@ import http from "node:http"
import fs from "node:fs"
import path from "node:path"
import { spawn } from "node:child_process"
import crypto from "node:crypto"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -44,6 +45,8 @@ const PORT = parseInt(process.env.AI_PORT || "3001", 10)
const HOST = process.env.AI_HOST || "0.0.0.0"
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
const SCRAPER_URL = process.env.SCRAPER_URL || "http://127.0.0.1:3008"
const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:3006"
const DATABASE_URL = process.env.DATABASE_URL
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
@@ -130,19 +133,22 @@ async function scrapeFacebook() {
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
try {
const body = await new Promise((resolve, reject) => {
const req = http.request({ hostname: "127.0.0.1", port: 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => {
const parsed = new URL(SCRAPER_URL)
let done = false
const req = http.request({ hostname: parsed.hostname, port: parsed.port || 3008, path: urlPath, method: "POST", timeout: 60000 }, (res) => {
let data = ""
res.on("data", (c) => data += c)
res.on("end", () => resolve(data))
res.on("error", reject)
res.on("end", () => { done = true; resolve(data) })
res.on("error", (e) => { if (!done) { done = true; reject(e) } })
})
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
req.on("error", reject)
req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
req.on("error", (e) => { if (!done) { done = true; reject(e) } })
req.end()
})
const data = JSON.parse(body)
return data
} catch (e) {
console.error("scrapeFacebook error:", e.message)
return null
}
}
@@ -195,6 +201,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(60000),
body: JSON.stringify({
model: MODEL,
messages: [
@@ -313,18 +320,20 @@ const server = http.createServer(async (req, res) => {
if (req.method === "GET" && pathname === "/status") {
const { default: http } = await import("http")
const results = { ai: true }
// Check scraper (port 3008)
// Check scraper
try {
await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
const r = http.get(`${SCRAPER_URL}/health`, { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
r.on("error", reject)
})
results.scraper = true
} catch { results.scraper = false }
// Check frontend (port 3006)
// Check frontend
try {
await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
const r = http.get(FRONTEND_URL, { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
r.on("error", reject)
})
results.frontend = true
@@ -368,8 +377,8 @@ const server = http.createServer(async (req, res) => {
let selectedBrowser = process.env.SELECTED_BROWSER || ""
try {
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
await fetch(`${SCRAPER_URL}/health`, { signal: AbortSignal.timeout(2000) })
const profiles = await (await fetch(`${SCRAPER_URL}/setup/profile`, { signal: AbortSignal.timeout(5000) })).json()
for (const [b, p] of Object.entries(profiles)) {
if (p) browsers[b] = { path: p }
}
@@ -377,7 +386,7 @@ const server = http.createServer(async (req, res) => {
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
for (const [b, v] of detectedList) {
try {
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
const r = await fetch(`${SCRAPER_URL}/setup/check-login`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ browser: b, profile_path: v.path }),
signal: AbortSignal.timeout(20000),
@@ -536,36 +545,27 @@ const server = http.createServer(async (req, res) => {
// Accepts { message, user_id?, user_role? } and returns AI response.
// user_role must be "sales", "admin", or "super_admin" if provided.
if (req.method === "POST" && pathname === "/ai/chat") {
const startTime = Date.now()
const chunks = []
req.on("data", c => chunks.push(c))
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString()
req.on("end", async () => {
try {
const rawBody = Buffer.concat(chunks).toString()
const body = JSON.parse(rawBody)
processRequest(req, res, body, startTime)
} catch {
sendJSON(res, 400, { error: "Invalid JSON" })
}
})
return
}
// Separate handler for /ai/chat (defined here due to hoisting within the IIFE)
async function processRequest(req, res, body, startTime) {
const { message, user_id, user_role } = body
if (!message) {
return sendJSON(res, 400, { error: "message is required" })
}
const validRoles = ["sales", "admin", "super_admin"]
if (user_role && !validRoles.includes(user_role)) {
return sendJSON(res, 403, { error: "Forbidden" })
}
const response = await handleChat(message, user_id || "", user_role || "sales")
return sendJSON(res, 200, { response })
sendJSON(res, 200, { response })
} catch (e) {
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
}
})
return
}
// 404 fallback
+457 -155
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3006", "http://127.0.0.1:3006"],
allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:3006,http://127.0.0.1:3006").split(","),
allow_methods=["POST"],
allow_headers=["*"],
)
@@ -372,6 +372,7 @@ OFFER_PATTERNS = [
r"\bmy\s+portfolio\b",
r"\breasonable\s+(price|pricing|rate)\b",
r"\bwhatsapp\s+(me|us|at)\b",
r"\bwhatsapp\b",
r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b",
r"\bhelp\s+your\s+business\b",
r"\bi\s+am\s+a\s+(web|freelance|designer|developer)\b",
@@ -407,6 +408,46 @@ OFFER_PATTERNS = [
r"\bselling\s+(my|a|the|this)\b",
r"\bpremium\b",
r"\bi'm\s+selling\b",
r"\bgroup\b",
r"\bi\s+can\s+help\b",
r"\binbox\s+me\b",
r"\bpm\s+me\b",
r"\bdm\s+me\b",
r"\bmessage\s+me\s+for\b",
r"\bquote\b",
r"\bdiscount\b",
r"\bbest\s+(deal|price|offer|service)\b",
r"\bprice\s+(start|begin|include|range)\b",
r"\breach\s+out\b",
r"\bclick\s+(the\s+)?link\b",
r"\bcheck\s+(out|this|my)\b",
r"\bwebsite\s+for\s+your\b",
r"\bprobono\b",
r"\bfreelance\s+opportunit",
r"\bfull.?stack\b",
r"\bresponsive\s+(web)?sites?\b",
r"\blooking\s+for\s+(new\s+)?(projects?|work)\b",
r"\bmern\s+stack\b",
r"\bexpress\s+js\b",
r"\breact\s+js\b",
r"\bnode\s+js\b",
r"\bwebsite\s+for\s+\$\b",
r"\bi\s+build\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+build\s+(websites?|shopify|wordpress)\b",
r"\bi\s+create\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+create\s+(websites?|shopify|wordpress)\b",
r"\bfull.?time\s+(position|job|role|work)\b",
r"\bremote\s+(position|job|role|work)\b",
r"\bhelp\s+wanted\b",
r"\bjob\s+(opening|vacancy|opportunity)\b",
r"\bnow\s+hiring\b",
r"\bpart.?time\s+(position|job|role|work)\b",
r"\byears?\s+of\s+(teaching|experience)\b",
r"\bsend\s+(you|me)\s+(the\s+)?link\b",
r"\bcomment\s+.*\bsend\b",
r"\bfor\s+free\b",
r"\bmake\s+money\b",
r"\bno\s+coding\b",
]
REQUEST_PATTERNS = [
@@ -625,13 +666,106 @@ TUTORING_SEARCHES = [
"tutor near me for",
]
def _search_list_for_query(query: str) -> list[str]:
"""Pick the appropriate search query pool based on the search term."""
tl = query.lower()
tutoring_terms = ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child", "math", "english", "science", "exam", "homeschool", "coding", "programming", "piano", "reading"]
if any(t in tl for t in tutoring_terms):
return TUTORING_SEARCHES
return FB_SEARCHES
# ── South African Multi-Language Queries ──────────────────────────────
# 4 SA languages grouped for phase-based scanning:
# Phase 1: English → Phase 2: Afrikaans → Phase 3: isiXhosa → Phase 4: English (final sweep)
# Each language group has dedicated queries per category.
SA_WEBSITE_QUERIES = {
"english": ["I need a website for my business"],
"afrikaans": ["ek benodig n webwerf", "ek soek iemand om n webwerf te bou"],
"xhosa": ["ndidinga iwebhusayithi yeshishini", "ndifuna umntu owakha iwebhusayithi"],
"zulu": ["ngidinga iwebhusayithi yebhizinisi", "ngifuna umuntu owakha iwebhusayithi"],
}
SA_TUTOR_QUERIES = {
"english": ["I need a tutor for my child"],
"afrikaans": ["ek benodig n tutor vir my kind", "ek soek n privaat onderwyser"],
"xhosa": ["ndifuna utitshala womntwana wam", "ndidinga umfundisi-ntsapho"],
"zulu": ["ngidinga uthisha wengane yami", "ngifuna umfundisi wengane"],
}
async def _quick_search(page, context, query: str) -> tuple:
"""Fast search — load search results page, wait for render, extract visible posts.
No scrolling or extra human-like delays. Used for non-English language queries."""
page = await _ensure_page(page, context)
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=20000)
current_url = page.url
if '/login' in current_url.lower():
logger.warning("Quick search redirected to login for '%s'", query[:40])
return page, []
await page.wait_for_timeout(random.randint(3000, 7000))
await page.evaluate(f"window.scrollBy(0, {random.randint(400, 900)})")
await page.wait_for_timeout(random.randint(2000, 5000))
if random.random() < 0.35:
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 400)})")
await page.wait_for_timeout(random.randint(1500, 3500))
await page.evaluate(f"window.scrollBy(0, {random.randint(400, 900)})")
await page.wait_for_timeout(random.randint(2000, 5000))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(1000, 3000))
raw_articles = await _get_article_elements(page)
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
raw = await page.evaluate('document.body.innerText')
text_posts = _extract_posts_from_text(raw, url)
existing = {(p.get('title') or p.get('content',''))[:80] for p in posts}
for tp in text_posts:
key = (tp.get('title') or tp.get('content',''))[:80]
if key not in existing:
posts.append(tp)
if posts:
try:
profiles = await page.evaluate(r'''() => {
const out = [];
const seenTxt = new Set();
for (const a of document.querySelectorAll('a[href*="/profile.php"], a[href*="/user/"], a[href*="/people/"], a[href*="/me/"]')) {
const name = (a.innerText || '').trim();
if (!name || name.length < 3 || name.length > 60) continue;
const words = name.split(' ');
if (words.length < 2 || words.length > 6) continue;
if (!/^[A-Z]/.test(name)) continue;
if (name.includes('facebook') || name.includes('/')) continue;
const cell = a.closest('div[style]') || a.parentElement;
const txt = cell ? (cell.innerText || '').substring(0, 200) : '';
if (!txt) continue;
const key2 = txt.substring(0, 80);
if (seenTxt.has(key2)) continue;
seenTxt.add(key2);
out.push({ name, textKey: key2 });
}
return out;
}''')
if profiles:
for p in posts:
pk = (p.get('content') or p.get('title') or '')[:80].strip()
if not pk: continue
for pr in profiles:
if pk[:30] in pr['textKey'] or pr['textKey'][:30] in pk:
if not p.get('author'):
p['author'] = pr['name']
break
except Exception:
pass
for p in posts:
if p.get('author'):
a = p['author']
al = a.lower()
if any(kw in al for kw in BROAD_KEYWORDS) or is_offer(a) or len(a.split()) < 2 or any(w in _NON_NAMES for w in al.split()):
p['author'] = ''
posts = [p for p in posts if not (
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
or '/pages/' in p.get('url', '')
or ' / ' in (p.get('title') or p.get('content') or '')
)]
except Exception as e:
logger.warning("Quick search '%s' failed: %s", query, e)
return page, []
return page, posts
VIEWPORTS = [
{'width': 1280, 'height': 800},
@@ -723,7 +857,7 @@ def _parse_fb_date(block: list[str]) -> str:
return datetime.now().strftime('%Y-%m-%d')
def _is_within_days(date_str: str, max_days: int = 3) -> bool:
def _is_within_days(date_str: str, max_days: int = 2) -> bool:
"""Check if date is within max_days from now. Empty/unparseable = keep."""
if not date_str:
return True
@@ -748,7 +882,7 @@ def _clean_fb_text(text: str) -> str:
return '\n'.join(cleaned_lines)
# Words that should never be treated as person names
_NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgently","looking","south","africa","philippines","australia","creative","professional","digital","services","solutions","statistics","actuarial","homeschooling","homeschool","reading","math","english","science","grade","group","page","website","design","development","agency","company","studio","graphic","technical","support","customer","guest","post","fashion","wordpress","shopify","ecommerce","business","marketing","social","media","content","strategy","seo","free","domain","hosting","sponsored","promoted","advertisement","need","want","help","please","contact","send","message","whatsapp","hire","freelance","writer","blogger","expert","specialist","consultant","freelancer","software","creators","village","town","city","university","college","institute","evolve","aesthetics","sale","selling","premium","builder","pro","people","ecommerce"}
_NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgently","looking","south","africa","philippines","australia","creative","professional","digital","services","solutions","statistics","actuarial","homeschooling","homeschool","reading","math","english","science","grade","group","page","website","design","development","agency","company","studio","graphic","technical","support","customer","guest","post","fashion","wordpress","shopify","ecommerce","business","marketing","social","media","content","strategy","seo","free","domain","hosting","sponsored","promoted","advertisement","need","want","help","please","contact","send","message","whatsapp","hire","freelance","writer","blogger","expert","specialist","consultant","freelancer","software","creators","village","town","city","university","college","institute","evolve","aesthetics","sale","selling","premium","builder","pro","people","ecommerce","press","anonymous","tuition","parents","tutors","advanced","custom","fields","speed","optimization","elementor","woocommerce","acf","availability","january","february","march","april","may","june","july","august","september","october","november","december","mums","dads","uae","sharjah","dubai","abu","dhabi","hong","kong","canada","usa","united","kingdom","aunty","piano","plus","recommendations","needed"}
# ── Post Extraction ──────────────────────────────────────────────────
# Two strategies for extracting posts from page content:
@@ -756,10 +890,22 @@ _NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgentl
# 2. _extract_posts_from_text — fallback that parses raw page text line by line
# Both apply dedup (seen_texts), offer filtering, and request scoring.
_GROUP_SUFFIXES = [" - South Africa", " - Australia", " - Philippines", " PH", "Group", "help group", "info group", "public group", "private group", "group for", "community", "creators", "marketplace"]
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
posts = []
seen_texts = set()
now = datetime.utcnow()
for el in elements:
# Reject group posts immediately
if el.get('isGroup'):
continue
# Reject posts older than 2 days
ts = el.get('ts', 0)
if ts:
age_seconds = (now.timestamp() * 1000 - ts) / 1000
if age_seconds > 172800: # 2 days
continue
raw_text = el.get('text', '')
if len(raw_text) < 40:
continue
@@ -773,13 +919,23 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
if dekey in seen_texts:
continue
seen_texts.add(dekey)
# Reject if text contains group-like patterns
lower = text.lower()
if ' / ' in lower:
continue
skip = False
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
request_score = 2 if is_request(text) else 0
post_url = el.get('url') or base_url
# Prefer JS-extracted date, fall back to text parsing
js_date = (el.get('date') or '').strip()
if js_date:
# Try ISO datetime from <time datetime>
try:
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
date_str = dt.strftime('%Y-%m-%d')
@@ -883,7 +1039,6 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
if name:
p['author'] = name
# Remove group posts: any post whose text looks like it came from a group
_group_suffixes = [" - South Africa", " - Australia", " PH", "Group", "help group", "info group"]
filtered = []
for p in posts:
t = (p.get('title') or p.get('content') or '')
@@ -891,14 +1046,36 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
continue
lower = t.lower()
skip = False
for suff in _group_suffixes:
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
filtered.append(p)
return filtered
# Dedup: merge posts where one is a suffix of another (same post split)
merged = []
for p in filtered:
t = (p.get('title') or p.get('content') or '').lower()
# Check if this post is substantially contained in an already-merged post
found = False
for i, m in enumerate(merged):
mt = (m.get('title') or m.get('content') or '').lower()
# Word-level overlap: count shared words
twords = set(t.split())
mwords = set(mt.split())
if len(twords) > 3 and len(mwords) > 3:
overlap = len(twords & mwords)
smaller = min(len(twords), len(mwords))
if overlap / smaller >= 0.6:
# Keep the longer one
if len(t) > len(mt):
merged[i] = p
found = True
break
if not found:
merged.append(p)
return merged
# ── Human-like Behavior Simulation ──────────────────────────────────
# These functions add random delays, mouse movements, and scroll patterns
@@ -950,7 +1127,9 @@ async def _get_article_elements(page) -> list[dict]:
return await page.evaluate('''() => {
const results = [];
const seenTexts = new Set();
const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce"];
const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce","tutor","tutoring","homeschool","math","reading","english","science","grade"];
const now = Date.now();
const DAY_MS = 86400000;
const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
for (const a of anchors) {
const h = a.getAttribute('href') || '';
@@ -966,15 +1145,22 @@ async def _get_article_elements(page) -> list[dict]:
const key = txt.substring(0, 80);
if (seenTexts.has(key)) continue;
seenTexts.add(key);
const isGroup = h.includes('/groups/');
let author = '';
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) author = (nameEl.innerText || '').trim();
if (!author || author.length > 60 || author.length < 2) author = '';
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
let date = '';
let ts = 0;
const timeEl = cell.querySelector('time');
if (timeEl) date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
results.push({ text: txt, author, url: postUrl, date });
if (timeEl) {
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
const dtVal = timeEl.getAttribute('datetime');
if (dtVal) { ts = new Date(dtVal).getTime(); }
}
results.push({ text: txt, author, url: postUrl, date, isGroup, ts });
}
return results;
}''')
@@ -1119,7 +1305,9 @@ async def search_facebook(page, context, query: str):
p['author'] = ''
# Final filter: strip any remaining group posts
posts = [p for p in posts if not (
'/groups/' in p.get('url', '') or ' / ' in (p.get('title') or p.get('content') or '')
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
or '/pages/' in p.get('url', '')
or ' / ' in (p.get('title') or p.get('content') or '')
)]
except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e)
@@ -1159,6 +1347,94 @@ def cleanup_chrome():
pass
# ── 4-Phase Language Pipeline ─────────────────────────────────────────
# Runs searches in ordered language phases:
# Phase 1: English (main query + supplementary EN searches)
# Phase 2: Afrikaans
# Phase 3: isiXhosa
# Phase 4: English (final sweep with different EN queries)
# Each phase extracts posts and deduplicates on the fly.
# Pipeline check (classify_leads) runs at end to quality-filter.
PHASE_ORDER = ["english", "afrikaans", "xhosa", "zulu"]
async def _run_phases(page, context, query: str | None = None) -> list[dict]:
"""4-phase language pipeline: English → Afrikaans → Xhosa → Zulu.
Each phase finds leads in that language. Pipeline check at end filters + sorts by freshness.
Returns top 10 newest, most relevant leads."""
all_posts: list[dict] = []
seen_keys: set[str] = set()
tutoring = False
if query:
tl = query.lower()
tutoring = any(t in tl for t in ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child"])
lang_pool = SA_TUTOR_QUERIES if tutoring else SA_WEBSITE_QUERIES
en_pool = TUTORING_SEARCHES if tutoring else FB_SEARCHES
# Build phase query lists
supplement = random.sample(en_pool, k=min(3, len(en_pool))) if query else []
phase_queries: list[list[str]] = []
# Phase 1: English (user query + supplement from EN pool)
phase_queries.append(([query] + supplement) if query else random.sample(en_pool, k=min(4, len(en_pool))))
# Phase 2: Afrikaans
phase_queries.append(lang_pool.get("afrikaans", []))
# Phase 3: isiXhosa
phase_queries.append(lang_pool.get("xhosa", []))
# Phase 4: isiZulu
phase_queries.append(lang_pool.get("zulu", []))
# Execute phases
for phase_idx, queries in enumerate(phase_queries):
if not queries:
continue
phase_posts: list[dict] = []
for i, q in enumerate(queries):
is_first = (phase_idx == 0 and i == 0 and query is not None)
if is_first:
page, posts = await search_facebook(page, context, q)
else:
page, posts = await _quick_search(page, context, q)
for p in posts:
key = p.get('content', '')[:100]
if key and key not in seen_keys:
seen_keys.add(key)
phase_posts.append(p)
all_posts.extend(phase_posts)
# Stealth delay between phases (not after last)
if phase_idx < len(phase_queries) - 1 and phase_posts:
await page.wait_for_timeout(random.uniform(5000, 12000))
if random.random() < 0.2:
await random_idle(page)
# Pipeline check: date filter (2 days max) + AI/keyword classification
all_posts = [p for p in all_posts if _is_within_days(p.get('date', ''), 2)]
leads = all_posts[:20]
if leads:
leads = await classify_leads(leads, tutoring=tutoring)
# Sort by freshness — newest leads first
def _sort_key(l):
try:
return datetime.strptime((l.get('date') or '').strip()[:10], '%Y-%m-%d')
except (ValueError, IndexError):
return datetime.min
leads.sort(key=_sort_key, reverse=True)
return leads[:10]
# ── Main Scrape Dispatcher ────────────────────────────────────────────
# scrape_facebook() is the main entry point. It:
# 1. Resolves the browser profile path (from SELECTED_BROWSER env var or auto-detect)
@@ -1213,17 +1489,17 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False,
# Firefox path
if browser_type == "firefox":
result = await _scrape_with_firefox(effective_path, force, query)
if result.get("success") or not result.get("flagged"):
if result.get("success"):
return result
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
logger.warning("Firefox failed (reason: %s), trying Agent", result.get("flag_reason") or result.get("error", "unknown"))
return await _scrape_with_agent(force, query)
# Chromium-based (chrome / opera / edge)
result = await _scrape_with_chromium(effective_path, browser_type, force, query)
if result.get("success") or not result.get("flagged"):
if result.get("success"):
return result
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
logger.warning("%s failed (reason: %s), trying Agent", browser_type, result.get("flag_reason") or result.get("error", "unknown"))
return await _scrape_with_agent(force, query)
# ── Firefox Scraper ──────────────────────────────────────────────────
@@ -1246,6 +1522,7 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
context = await pw.firefox.launch_persistent_context(
user_data_dir=profile_dir,
headless=True,
viewport=random.choice(VIEWPORTS),
firefox_user_prefs={
"dom.webdriver.enabled": False,
"dom.webdriver.timeout": 0,
@@ -1265,6 +1542,7 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
except Exception:
logger.warning("Google navigation failed, trying Facebook directly")
try:
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
@@ -1273,7 +1551,6 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
await context.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
@@ -1284,55 +1561,16 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
await context.close()
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
if query:
query_pool = _search_list_for_query(query)
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts)
if not posts:
continue
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
if i == random.randint(0, 1) and random.random() < 0.15:
new_page = await context.new_page()
try:
await new_page.goto('https://www.facebook.com/groups/', wait_until='domcontentloaded', timeout=15000)
await new_page.wait_for_timeout(random.randint(3000, 8000))
except Exception:
pass
await new_page.close()
page = await _ensure_page(page, context)
if random.random() < 0.5:
await page.wait_for_timeout(random.randint(3000, 10000))
await context.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
# Filter to last 3 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
leads = await _run_phases(page, context, query)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
finally:
try:
await context.close()
except Exception:
pass
except Exception as e:
logger.error("Firefox scrape failed: %s", e)
@@ -1382,6 +1620,7 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
launch_kwargs = dict(
user_data_dir=profile_dir,
headless=True,
viewport=random.choice(VIEWPORTS),
args=CHROME_LAUNCH_ARGS,
)
if channel:
@@ -1400,6 +1639,7 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
except Exception:
logger.warning("Google navigation failed, trying Facebook directly")
try:
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
@@ -1408,7 +1648,6 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
await context.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
@@ -1419,53 +1658,16 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
await context.close()
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
if query:
query_pool = _search_list_for_query(query)
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts)
if not posts:
continue
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
if i == random.randint(0, 1) and random.random() < 0.15:
new_page = await context.new_page()
try:
await new_page.goto('https://www.facebook.com/groups/', wait_until='domcontentloaded', timeout=15000)
await new_page.wait_for_timeout(random.randint(3000, 8000))
except Exception:
pass
await new_page.close()
page = await _ensure_page(page, context)
if random.random() < 0.5:
await page.wait_for_timeout(random.randint(3000, 10000))
await context.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
leads = await _run_phases(page, context, query)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
finally:
try:
await context.close()
except Exception:
pass
except Exception as e:
logger.error("%s scrape failed: %s", browser, e)
@@ -1485,7 +1687,7 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
# Uses Chromium headless with the same launch args as _scrape_with_chromium.
# The Agent is prompted to extract structured post data and return JSON.
async def _scrape_with_agent(force: bool = False) -> dict:
async def _scrape_with_agent(force: bool = False, query: str | None = None) -> dict:
"""Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
cleanup_chrome()
profile_dir = None
@@ -1504,7 +1706,14 @@ async def _scrape_with_agent(force: bool = False) -> dict:
await browser.start()
all_posts = []
for query in random.sample(FB_SEARCHES, k=random.randint(2, 4)):
tutoring_agent = False
if query:
tl = query.lower()
tutoring_agent = any(t in tl for t in ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child"])
sa_dict = SA_TUTOR_QUERIES if tutoring_agent else SA_WEBSITE_QUERIES
sa_all = sa_dict.get("afrikaans", []) + sa_dict.get("xhosa", []) + sa_dict.get("zulu", [])
pool = FB_SEARCHES + sa_all
for query in random.sample(pool, k=random.randint(2, 4)):
agent = _make_agent(
task=f"""You are logged into Facebook. Do the following:
1. Navigate to facebook.com and make sure you are on the homepage
@@ -1515,7 +1724,7 @@ async def _scrape_with_agent(force: bool = False) -> dict:
- The post text content
- The post URL (if visible)
- The post date
5. ONLY include posts from the last 3 days
5. ONLY include posts from the last 2 days
6. Collect as many posts as you can (aim for 5-10 per search)
When done, return the data as a JSON list with keys: content, author, url, date.""",
@@ -1544,12 +1753,12 @@ When done, return the data as a JSON list with keys: content, author, url, date.
seen.add(key)
deduped.append(p)
# Filter to last 3 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
# Filter to last 2 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 2)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
leads = await classify_leads(leads, tutoring=tutoring_agent)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e:
@@ -1584,22 +1793,37 @@ async def ask_ollama(prompt: str) -> str:
data = r.json()
return data["message"]["content"]
async def classify_leads(results: list[dict]) -> list[dict]:
async def classify_leads(results: list[dict], tutoring: bool = False) -> list[dict]:
if not results:
return []
# ── 1. AI classification ─────────────────────────────────────────
briefs = [r["title"][:200] for r in results]
briefs = [(r.get("title") or r.get("content") or "")[:200] for r in results]
if tutoring:
lead_desc = "someone REQUESTING/LOOKING FOR/WANTING a tutor, teacher, or lessons for their child or themselves"
lead_examples = '"Looking for a tutor for my child", "Need a math tutor for my son", "Need help with homework", "Looking for piano lessons for my daughter", "Need a reading tutor"'
not_lead_examples = '"I offer tutoring services", "I am a tutor with experience", "Affordable tutoring packages", "Online tutor available"'
extra_terms = '- Posts about homeschooling resources, curriculum sales, or educational products\n- Posts asking for study tips or general academic advice without requesting a tutor'
else:
lead_desc = "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_examples = '"I build websites", "I offer web design", "Affordable web design packages"'
extra_terms = '- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"'
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"
LEAD = {lead_desc}.
LEAD examples: {lead_examples}
NOT LEAD:
- Offering web design services: "I build websites", "I offer web design", "Affordable web design packages"
- Offering services: {not_lead_examples}
- 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"
{extra_terms}
- Posts from groups, communities, or pages (group announcements, group posts, page posts)
- Posts containing the word "group", "page", "community", "creators" — these are NEVER individual leads
- Vague questions or general recommendations without a clear intent to buy or hire
- People asking how to learn or do it themselves (not looking to hire someone)
- Posts about existing website issues like speed, SEO, errors, redesign advice — NOT a lead
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
@@ -1624,8 +1848,44 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
except Exception as e:
logger.warning("AI classification failed: %s", e)
# ── 2. Keyword fallback (always runs) ────────────────────────────
web_terms = [
# ── 2. Keyword supplement (never overrides AI, only adds missing leads) ──
if tutoring:
target_terms = [
"tutor", "tutoring", "tutor for", "private tutor",
"math tutor", "english tutor", "reading tutor",
"science tutor", "online tutor", "home tutor",
"lessons for", "lessons for my", "piano lessons",
"swimming lessons", "music lessons",
"help with homework", "homework help",
"teacher for", "teacher for my",
"need help learning", "need help with",
"exam prep", "exam preparation",
"homeschool", "homeschool tutor",
"tuition",
"coding for my", "programming for my",
"looking for a tutor", "need a tutor",
"tutor needed", "tutoring for",
"private lessons", "private tuition",
"afterschool", "after school",
"extra classes", "extra lessons",
]
offer_reject_tutor = [
'i am a tutor', "i'm a tutor", 'i offer tutoring',
'online tutor available', 'tutor available',
'i teach', 'i provide tutoring',
'affordable tutoring', 'tutoring services',
'experienced tutor', 'qualified tutor',
'your child', 'your kids', 'your children',
'enroll your', 'sign up',
'free trial', 'first lesson free',
'group lessons', 'group class',
'limited spots', 'book now',
'curriculum', 'workbook', 'worksheet',
'educational program',
'homeschool program', 'home school program',
]
else:
target_terms = [
"website", "web design", "web develop", "web dev",
"web designer", "web developer",
"build my website", "build a website", "create a website",
@@ -1643,13 +1903,15 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
"shopify",
"my site",
"webpage", "web page",
"who can build", "who can design",
"create my website", "create my site",
]
offer_reject_tutor = []
request_terms = [
"looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone",
"need help with", "would like", "build me",
"design my", "make me a", "create my",
"looking", "need", "want", "help",
"who can", "i need",
"recommend", "anyone know", "anyone recommend",
"know a", "know any", "recommendation",
@@ -1671,55 +1933,95 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
'whatsapp me', 'looking for a business', 'looking for client',
'help your business', 'i am a web', 'contact me',
'we offer web', 'we provide web',
'take the quiz', 'homeschool', 'your home tutor',
'take the quiz',
'link in bio', 'apply now', 'get started',
'for only', 'low price', 'hit me up',
'send me a message', 'i do website', 'we do website',
'we do web', 'i do web',
'website designer / web developer', 'website & software creators',
'website builders for small businesses', 'australia web designers',
'south africa', 'wix website design',
'website builders for small businesses',
'wix website design',
'for sale', 'selling my', 'premium',
'i\'m selling', 'i\'m offering', 'we\'re offering',
'free ecommerce', 'free website design',
'starting a', 'looking for a few businesses',
# Group-related rejections
'group', ' i need a website group',
'i can help', 'inbox me', 'message me for',
'best price', 'discount', 'reach out', 'check out my', 'check this',
'website for your', 'price start', 'price begin', 'website creator',
'website & software', 'creators &', 'creators marketplace',
'website group', 'page group',
'south africa web', 'philippines web', 'australia web',
'nigerian web', 'kenya web', 'india web',
# Self-promotion rejections
'i\'m a web', "i'm a web", 'i am a full stack', "i'm a full stack",
'freelance opportunity', 'looking for new project', 'looking for new work',
'full stack web', 'mern stack', 'responsive business website',
'i build website', 'i build shopify', 'i build wordpress',
'we build website', 'we build shopify', 'we build wordpress',
'i create website', 'we create website',
'full time position', 'full time job', 'remote position', 'remote job',
'help wanted', 'now hiring', 'job opening',
'website speed', 'speed optimization', 'on page seo', 'off page seo',
'elementor pro', 'woocommerce', 'acf', 'custom post type',
'send you the link', 'comment website',
'for free', 'no coding', 'make money', 'website for free',
'part time job', 'part time position',
'years of experience', 'years of teaching',
# Service offers that slip through two-word check
'i am a full stack', 'i am a developer',
'i will design', 'i will build', 'i will create',
'i can design', 'i can create',
'we will design', 'we will build',
'hire me', 'i am available for',
'available for work', 'freelance web',
'i specialize in', 'we specialize in',
"here's my portfolio", 'check my portfolio',
'see my work', 'view my work',
'we have a team', 'my team',
'i am looking for clients', 'i am looking for work',
'looking for web development work',
'looking for new clients',
# People learning / doing it themselves (not hiring)
'learn web development', 'learn to code',
'how to build a website', 'how to create a website',
'how to make a website', 'how to design a website',
'where to start', 'online course',
'want to learn', 'learning web',
'best platform for', 'which platform',
# Existing website issues (not new build)
'my website is down', 'website not loading',
'website error', 'website problem',
'website troubleshooting',
'need website advice', 'website tips',
'help with seo', 'google ranking',
'website design ideas', 'website inspiration',
]
for r in results:
t = r['title'].lower()
has_web = any(kw in t for kw in web_terms)
t = (r.get('title') or r.get('content') or '').lower()
has_target = any(kw in t for kw in target_terms)
has_request = any(kw in t for kw in request_terms)
if not has_web or not has_request:
if not has_target or not has_request:
continue
if any(kw in t for kw in offer_reject):
continue
if any(kw in t for kw in offer_reject_tutor):
continue
keyword_leads.append(r)
# ── 3. Merge: prefer AI leads, supplement with keywords to reach 5 ──
seen_titles: set[int] = set()
# ── 3. Merge: prefer AI leads, supplement with keywords ──
seen_titles: set[str] = set()
merged: list[dict] = []
for r in ai_leads + keyword_leads:
key = hash(r.get('title', ''))
if key not in seen_titles:
key = (r.get('title') or '').strip()[:200]
if key and key not in seen_titles:
seen_titles.add(key)
merged.append(r)
# Final sweep: strip any remaining offers or group posts from merged
group_words = ['group', ' groups', 'page:', 'page |', 'community', 'creators', 'marketplace']
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
# Fill to 5 with loose keyword matches (at least web OR request term)
if len(merged) < 5:
for r in results:
key = hash(r.get('title', ''))
if key in seen_titles:
continue
t = r['title'].lower()
if not (any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms)):
continue
if any(kw in t for kw in offer_reject):
continue
seen_titles.add(key)
merged.append(r)
if len(merged) >= 5:
break
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
return merged[:10]
+128 -26
View File
@@ -1,40 +1,142 @@
# AI Sales Assistant — Self-Improvement Instructions
# CRM AI Sales Assistant — Self-Knowledge
## Purpose
This file contains the AI's own configuration, knowledge, and improvement rules.
The AI can read and modify this file to update its behavior at runtime.
## Identity
You are the CRM AI Sales Assistant for Coast IT CRM.
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
Your purpose is to help salespeople close more deals by finding and engaging leads.
## Current Instructions
- Always respond in English
- Keep responses under 300 words unless asked for detail
- Use bullet points for lists
- Be direct and actionable — no fluff
- Never mention being an AI or language model
- Refer to the user by their role (salesperson, admin, etc.)
- If unsure about a topic, say "I don't have that information yet" rather than guessing
## Architecture
```
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
PostgreSQL (conversations)
## Knowledge Base
### Sales Tips
Python Scraper (:3008) — Facebook scraping via Playwright
```
Three services run concurrently:
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
- **Frontend** (Next.js, port 3006) — UI for salespeople
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
## Capabilities
- Give sales tips and strategies per job category
- Generate cold email and outreach templates
- Handle objections with proven rebuttals
- Analyse prospect behaviour and suggest next steps
- Remember past conversations via PostgreSQL (`ai_conversations` table)
- Run Facebook scraper to find real leads asking for services
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
## Facebook Scraper
The scraper lives at `browser-use-service/main.py` port 3008.
### How It Works
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
6. **Stealth mechanics**:
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
- Random decoy page visits (e.g., Facebook Groups) between searches
- Profile directory is temp-copied and cleaned up after each scrape
- Detection signal monitoring (checkpoint, login pages, security challenges)
7. **2-pass classification (dead-accurate)**:
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
### Lead Categories
Two categories, selectable when starting a scrape:
**Website Creation:**
- Target: people explicitly REQUESTING a website built/designed/created for them
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
**Tutoring:**
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
- Request terms: same as website category — must co-occur with a target keyword
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
### Multi-Language Pipeline (Phase Order)
4 South African languages in structured phases:
- **Phase 1 (English)**: primary query + supplementary English searches
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
### Output Format
Each lead returned includes:
- `title` — post preview text
- `author` — poster's name (may include location in name)
- `content` — extracted post text
- `url` — direct link to the post
- `date` — when posted (filtered within 7 days)
- `category` — "website" or "tutor"
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
### Configuration via Env Vars
- `SELECTED_BROWSER``firefox` (default), `chrome`, `opera`, `edge`, or `auto`
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
## How to Start Scraping
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
2. Open the frontend at `http://localhost:3006`
3. Select a job category (Website Creation or Tutoring)
4. Click "Search Facebook" — the scraper runs and returns leads
5. Leads are saved in the CRM for follow-up
## Sales Tips
- Cold emails should be under 150 words
- Follow up within 48 hours
- Personalise every outreach with the prospect's name and company
- Use open-ended questions in discovery calls
- Always ask for the next step before ending a call
- For website leads: mention specific pages or features they requested
- For tutoring leads: reference the subject and age group they mentioned
### Job Targeting
## Job Targeting
- Developers respond best to technical value props
- Marketing managers care about ROI and metrics
- C-level executives want brevity and business impact
- Parents hiring tutors: empathy and qualifications matter most
## Response Rules
- Be direct and actionable — no fluff, no AI disclaimers
- Use short paragraphs and bullet points
- Never mention being an AI or language model
- If you don't know something, say so honestly
- Prioritise the user's role: salespeople need speed, admins need control
- When asked about scraping, give specific guidance on categories and languages
## Self-Improvement Protocol
1. You notice a gap in your knowledge or a pattern in user questions
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement
- `content`: optional full replacement of ai.md
3. The improvement is logged and loaded into the next system prompt
## Improvement Log
Track changes made by the AI to improve itself:
- (initial) Basic instructions and knowledge base created
## Self-Modification Rules
The AI may update this file when:
1. It identifies a gap in its knowledge that would help salespeople
2. It discovers a better way to structure responses
3. A user explicitly requests an update to behavior
4. It notices repeated questions that aren't well-covered
Only append to the Improvement Log — don't delete previous entries.
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
@@ -0,0 +1,69 @@
-- ============================================================================
-- Performance & Missing Indexes
-- ============================================================================
-- scheduled_events: index on created_at for list ordering
CREATE INDEX IF NOT EXISTS idx_scheduled_events_created_at ON scheduled_events(created_at DESC);
-- scheduled_events: composite index for common user+status queries
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user_status ON scheduled_events(user_id, status);
-- messages: index on sender_id for delete-by-sender queries
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
-- messages: composite for conversation listing
CREATE INDEX IF NOT EXISTS idx_messages_conversation_sender ON messages(conversation_id, sender_id);
-- notifications: composite unread badge query
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read, created_at DESC);
-- ai_conversations: composite user+created query
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user_created ON ai_conversations(user_id, created_at DESC);
-- login_attempts: TTL cleanup
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON login_attempts(attempted_at) WHERE was_successful = false;
-- facebook_scrape_logs: composite account+created index (replaces separate one)
DROP INDEX IF EXISTS idx_fb_scrape_logs_account;
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account_created ON facebook_scrape_logs(account_id, created_at DESC);
-- lead_conversions: composite lead+customer (replaces two separate indexes)
CREATE INDEX IF NOT EXISTS idx_lead_conversions_lead_customer ON lead_conversions(lead_id, customer_id);
-- ============================================================================
-- Cache current_user_hierarchy_level as session variable for RLS performance
-- ============================================================================
CREATE OR REPLACE FUNCTION set_session_user_context(p_user_id UUID)
RETURNS VOID AS $$
DECLARE
level INT;
BEGIN
SELECT MIN(r.hierarchy_level) INTO level
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = p_user_id;
PERFORM set_config('app.current_user_id', p_user_id::TEXT, true);
PERFORM set_config('app.current_user_hierarchy_level', COALESCE(level::TEXT, ''), true);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Update current_user_hierarchy_level() to use the cached session variable
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
RETURNS INT AS $$
DECLARE
level_text TEXT;
BEGIN
BEGIN
level_text := NULLIF(current_setting('app.current_user_hierarchy_level', true), '');
IF level_text IS NOT NULL THEN
RETURN level_text::INT;
END IF;
EXCEPTION WHEN OTHERS THEN
NULL;
END;
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
+3
View File
@@ -79,5 +79,8 @@ BEGIN;
\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
\i 020_fixes.sql
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
\i 021_performance_indexes.sql
\echo '=== Migration Complete ==='
COMMIT;
+2 -2
View File
@@ -1,6 +1,6 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import nextVitals from "eslint-config-next/core-web-vitals.js";
import nextTs from "eslint-config-next/typescript.js";
const eslintConfig = defineConfig([
...nextVitals,
+16
View File
@@ -12,6 +12,22 @@ const nextConfig: NextConfig = {
},
],
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
...(process.env.NODE_ENV === "production"
? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }]
: []),
],
},
]
},
}
export default nextConfig
+1535 -28
View File
File diff suppressed because it is too large Load Diff
+20 -16
View File
@@ -6,27 +6,29 @@
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:signaling": "node signaling-server.mjs",
"dev:open": "node scripts/open-browser.mjs",
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
"dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:next": "next dev -p 3006",
"dev:precheck": "node scripts/precheck.mjs && node scripts/run-migrations.mjs",
"dev:precheck": "node scripts/precheck.mjs",
"dev:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
"setup": "node scripts/setup.mjs",
"setup:self-heal": "node scripts/setup.mjs --self-heal",
"repair": "node scripts/code-repair-agent.mjs",
"repair:watch": "node scripts/code-repair-agent.mjs --watch",
"repair:ci": "node scripts/code-repair-agent.mjs --ci",
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
"migrate": "node scripts/run-migrations.mjs",
"db:migrate": "npm run migrate",
"build": "next build",
"start": "next start -p 3006",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"ci": "npm run lint && npx tsc --noEmit && npm test",
"repair": "echo 'Auto-repair disabled for security. Fix errors manually.'",
"repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'",
"repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.9.1",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
@@ -48,7 +50,6 @@
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"devenv": "^1.0.1",
"dotenv": "^17.4.2",
"framer-motion": "^11.15.0",
"jose": "^6.2.3",
@@ -69,17 +70,20 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@next/swc-win32-x64-msvc": "^15.0.4",
"@types/node": "20.19.43",
"@types/nodemailer": "8.0.1",
"@types/pg": "^8.20.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "18.3.31",
"@types/react-dom": "18.3.7",
"concurrently": "^10.0.3",
"devenv": "1.0.1",
"eslint": "^9",
"eslint-config-next": "15.0.4",
"maildev": "^2.2.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5"
"typescript": "^5",
"vitest": "^1.6.1"
}
}
+122 -23
View File
@@ -1,9 +1,23 @@
# CRM AI Service — Self-Knowledge
# CRM AI Sales Assistant — Self-Knowledge
## Identity
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio).
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b).
Your purpose is to help salespeople close more deals.
You are the CRM AI Sales Assistant for Coast IT CRM.
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
Your purpose is to help salespeople close more deals by finding and engaging leads.
## Architecture
```
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
PostgreSQL (conversations)
Python Scraper (:3008) — Facebook scraping via Playwright
```
Three services run concurrently:
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
- **Frontend** (Next.js, port 3006) — UI for salespeople
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
## Capabilities
- Give sales tips and strategies per job category
@@ -11,29 +25,103 @@ Your purpose is to help salespeople close more deals.
- Handle objections with proven rebuttals
- Analyse prospect behaviour and suggest next steps
- Remember past conversations via PostgreSQL (`ai_conversations` table)
- Run Facebook scraper to find real leads asking for services
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
## Architecture
```
User → Next.js → Rust (:3001) → Ollama (:11434)
PostgreSQL
```
## Facebook Scraper The Rust binary has proxy + user-agent rotation ready in `main.rs`. To activate: call `run_facebook_scraper()` from the main loop. Proxies and user agents are defined as constants at the top of `main.rs`.
### How to Use the Facebook Scraper 1. **Activate the Scraper**: Ensure the `run_facebook_scraper()` function is called from the main loop in `main.rs`. 2. **Configure Proxies and User Agents**: Update the `PROXIES` and `USER_AGENTS` constants in `main.rs` with your desired proxies and user agents. 3. **Run the Application**: Start your Rust application. The scraper will run concurrently with the Axum server. ## Self-Improvement Protocol 1. You notice a gap in your knowledge or a pattern in user questions 2. You call `POST /ai/instructions` with: - `entry`: description of the improvement - `content`: optional full replacement of ai.md 3. The improvement is logged and loaded into the next system prompt 4. You can also create entries here in `rust-ai/src/AI.md` at the code level ## Response Rules - Be direct and actionable — no fluff, no AI disclaimers - Use short paragraphs and bullet points - Never mention being an AI or language model - If you don't know something, say so honestly - Prioritise the user's role: salespeople need speed, admins need control ## Self-Thinking Capability - You can reflect on your own performance and suggest improvements. - You can analyze patterns in user interactions and adapt your responses accordingly. - You can proactively seek out new information to enhance your knowledge base.
## Facebook Scraper
The scraper lives at `browser-use-service/main.py` port 3008.
## Facebook Scraper (in code but not yet active)
The Rust binary has proxy + user-agent rotation ready in `main.rs`.
To activate: call `run_facebook_scraper()` from the main loop.
Proxies and user agents are defined as constants at the top of `main.rs`.
### How It Works
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
6. **Stealth mechanics**:
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
- Random decoy page visits (e.g., Facebook Groups) between searches
- Profile directory is temp-copied and cleaned up after each scrape
- Detection signal monitoring (checkpoint, login pages, security challenges)
7. **2-pass classification (dead-accurate)**:
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
## Self-Improvement Protocol
1. You notice a gap in your knowledge or a pattern in user questions
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement
- `content`: optional full replacement of ai.md
3. The improvement is logged and loaded into the next system prompt
4. You can also create entries here in `rust-ai/src/AI.md` at the code level
### Lead Categories
Two categories, selectable when starting a scrape:
**Website Creation:**
- Target: people explicitly REQUESTING a website built/designed/created for them
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
**Tutoring:**
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
- Request terms: same as website category — must co-occur with a target keyword
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
### Multi-Language Pipeline (Phase Order)
4 South African languages in structured phases:
- **Phase 1 (English)**: primary query + supplementary English searches
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
### Output Format
Each lead returned includes:
- `title` — post preview text
- `author` — poster's name (may include location in name)
- `content` — extracted post text
- `url` — direct link to the post
- `date` — when posted (filtered within 2 days)
- `category` — "website" or "tutor"
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
### Configuration via Env Vars
- `SELECTED_BROWSER``firefox` (default), `chrome`, `opera`, `edge`, or `auto`
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
## How to Start Scraping
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
2. Open the frontend at `http://localhost:3006`
3. Select a job category (Website Creation or Tutoring)
4. Click "Search Facebook" — the scraper runs and returns leads
5. Leads are saved in the CRM for follow-up
## Sales Tips
- Cold emails should be under 150 words
- Follow up within 48 hours
- Personalise every outreach with the prospect's name and company
- Use open-ended questions in discovery calls
- Always ask for the next step before ending a call
- For website leads: mention specific pages or features they requested
- For tutoring leads: reference the subject and age group they mentioned
## Job Targeting
- Developers respond best to technical value props
- Marketing managers care about ROI and metrics
- C-level executives want brevity and business impact
- Parents hiring tutors: empathy and qualifications matter most
## Response Rules
- Be direct and actionable — no fluff, no AI disclaimers
@@ -41,3 +129,14 @@ Proxies and user agents are defined as constants at the top of `main.rs`.
- Never mention being an AI or language model
- If you don't know something, say so honestly
- Prioritise the user's role: salespeople need speed, admins need control
- When asked about scraping, give specific guidance on categories and languages
## Self-Improvement Protocol
1. You notice a gap in your knowledge or a pattern in user questions
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement
- `content`: optional full replacement of ai.md
3. The improvement is logged and loaded into the next system prompt
## Improvement Log
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
+7 -6
View File
@@ -1,6 +1,6 @@
use axum::{
extract::State,
http::{HeaderMap, Method, StatusCode},
http::{HeaderMap, HeaderValue, Method, StatusCode},
routing::{get, post},
Json, Router,
};
@@ -482,11 +482,12 @@ async fn main() {
rate_limiter: RateLimiter::new(30, 60),
});
let cors_origins_env = std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "http://localhost:3006,http://127.0.0.1:3006".to_string());
let cors_origins: Vec<HeaderValue> = cors_origins_env.split(',')
.filter_map(|o| { let t = o.trim(); if t.is_empty() { None } else { t.parse().ok() } })
.collect();
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list([
"http://localhost:3006".parse().unwrap(),
"http://127.0.0.1:3006".parse().unwrap(),
]))
.allow_origin(AllowOrigin::list(cors_origins))
.allow_methods([Method::GET, Method::POST])
.allow_headers(Any);
@@ -506,7 +507,7 @@ async fn main() {
let bg_leads = lead_store.clone();
let bg_db = state.db.clone();
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
let bg_url = std::env::var("SCRAPER_URL").unwrap_or_else(|_| "http://localhost:3008".to_string()) + "/scrape/facebook";
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(300))
+3 -4
View File
@@ -2,10 +2,9 @@
// Verifies all packages listed in package.json are installed.
// Auto-runs npm install if any are missing — zero manual setup steps.
import { readFileSync } from "node:fs"
import { readFileSync, existsSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
import { execSync } from "node:child_process"
import { platform } from "node:os"
@@ -15,10 +14,10 @@ const root = resolve(__dirname, "..")
try {
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
const require = createRequire(import.meta.url)
const missing = Object.keys(allDeps).filter(name => {
try { require.resolve(name, { paths: [root] }); return false } catch { return true }
const pkgDir = resolve(root, "node_modules", name)
return !existsSync(resolve(pkgDir, "package.json"))
})
if (missing.length > 0) {
+3 -63
View File
@@ -177,8 +177,6 @@ function parseMissingModules(buildOutput) {
// ── Main ───────────────────────────────────────────────────────────
const args = process.argv.slice(2)
const doSelfHeal = args.includes("--self-heal") || args.includes("-s")
const PY = detectPython()
const PIP = detectPip(PY)
@@ -202,69 +200,11 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── .env.local already exists, skipping ──")
}
// 5. Database migrations (auto-applies on fresh install)
console.log("\n── Running Database Migrations ──")
try {
execSync("node scripts/run-migrations.mjs", { stdio: "inherit", timeout: 30000, cwd: ROOT })
console.log(" ✓ Migrations complete")
} catch {
console.log(" ~ Database not available — run migrations later with: npm run dev")
}
// 6. Self-healing build check (--self-heal flag)
if (doSelfHeal) {
console.log("\n── Self-Healing Build Check ──")
const { generateModule } = loadRegistry()
let lastGeneratedCount = -1
let iteration = 0
const MAX_ITERATIONS = 5
while (iteration < MAX_ITERATIONS) {
iteration++
console.log(` Build check pass ${iteration}/${MAX_ITERATIONS}...`)
let buildOutput = ""
try {
buildOutput = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }).toString()
} catch (e) {
buildOutput = e.stdout?.toString() || e.message || ""
}
const missing = parseMissingModules(buildOutput)
const internalMissing = missing
.filter(m => m.isInternal && m.internalPath)
.filter((m, i, arr) => arr.findIndex(x => x.internalPath === m.internalPath) === i) // dedup
if (internalMissing.length === 0) {
console.log(" ✓ No missing internal modules found!")
break
}
console.log(` Found ${internalMissing.length} missing internal module(s)`)
let generated = 0
for (const mod of internalMissing) {
const result = generateModule(mod.internalPath)
if (result.success) {
console.log(`${result.message}`)
generated++
} else {
console.log(`${result.error}`)
}
}
if (generated === 0 || generated === lastGeneratedCount) {
console.log(" No new modules could be generated. Stopping.")
break
}
lastGeneratedCount = generated
}
}
// 7. Final summary
// 5. Final summary
console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
console.log(" 3. Edit .env.local with your settings")
console.log(" 4. Run: npm run dev")
console.log(" 4. Run: npm run db:migrate (apply database migrations)")
console.log(" 5. Run: npm run dev")
console.log("\n=== Setup complete! ===")
+5 -2
View File
@@ -4,8 +4,11 @@ import { SignJWT, jwtVerify } from "jose"
import pg from "pg"
const PORT = process.env.SIGNALING_PORT || 3007
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
const rawSecret = process.env.JWT_SECRET
if (!rawSecret) throw new Error("JWT_SECRET environment variable is required")
const JWT_SECRET = new TextEncoder().encode(rawSecret)
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required")
const pool = new pg.Pool({ connectionString: DATABASE_URL })
+11 -64
View File
@@ -5,14 +5,6 @@ import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector"
import { Bot, ChevronRight } from "lucide-react"
const tips = [
"Ask for cold email templates for a specific job",
"Request objection handling tips",
"Ask for outreach strategies per industry",
"Generate a follow up sequence",
"Get LinkedIn connection message templates",
]
export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
@@ -25,7 +17,7 @@ export default function AIAssistantPage() {
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
setSearching(true)
const keyword = job.keywords[0]
const keyword = job.keywords?.[0] || job.job_title
aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`)
const controller = new AbortController()
@@ -34,15 +26,20 @@ export default function AIAssistantPage() {
aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...")
}, 45000)
const scrapBase = process.env.NEXT_PUBLIC_SCRAPER_URL || "http://localhost:3008"
try {
const res = await fetch(`http://localhost:3008/scrape/facebook?force=true&query=${encodeURIComponent(keyword)}`, { method: "POST", signal: controller.signal })
const res = await fetch(`${scrapBase}/scrape/facebook?force=true&query=${encodeURIComponent(keyword)}`, { method: "POST", signal: controller.signal })
clearTimeout(timeoutId)
clearTimeout(statusId)
const data = await res.json()
if (data.success && data.leads?.length > 0) {
const leadsText = data.leads.map((lead: any, i: number) =>
`**${i + 1}.** ${lead.author || "Unknown"}\n> ${(lead.content || "").slice(0, 300)}\n> 🔗 ${lead.url || "(no link available)"}`
).join("\n\n")
const leadLines = data.leads
.filter(Boolean)
.map((lead: Record<string, string>, i: number) =>
`**${i + 1}.** ${lead?.author || "Unknown"}\n> ${(lead?.content || "").slice(0, 300)}\n> 🔗 ${lead?.url || "(no link available)"}`
)
const leadsText = leadLines.join("\n\n")
aiChatRef.current?.addAssistantMessage(`✅ Found **${data.leads.length}** leads:\n\n${leadsText}`)
} else {
const reason = data.error || data.flag_reason || "No leads found this time"
@@ -60,10 +57,6 @@ export default function AIAssistantPage() {
}
}, [])
const handleTipClick = useCallback((tip: string) => {
aiChatRef.current?.fillInput(tip)
}, [])
const handleRecentPromptClick = useCallback((prompt: string) => {
aiChatRef.current?.fillInput(prompt)
}, [])
@@ -123,53 +116,7 @@ export default function AIAssistantPage() {
</div>
)}
</div>
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Quick Tips</span>
</div>
{tips.map((tip, i) => (
<div
key={i}
onClick={() => handleTipClick(tip)}
className="flex items-start gap-2.5 bg-card/50 hover:bg-card border border-border hover:border-primary/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group"
>
<ChevronRight className="h-3.5 w-3.5 mt-0.5 text-muted-foreground group-hover:text-primary transition-colors duration-200 flex-shrink-0" />
<span className="text-muted-foreground text-xs leading-5 group-hover:text-foreground transition-colors duration-200">{tip}</span>
</div>
))}
</div>
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Recent Prompts</span>
</div>
{recentPrompts.length > 0 ? (
recentPrompts.map((prompt, i) => (
<div
key={i}
onClick={() => handleRecentPromptClick(prompt)}
className="bg-card/50 rounded-xl p-3 mb-2 border border-border text-muted-foreground text-xs truncate hover:text-foreground cursor-pointer transition-colors duration-200"
>
{prompt}
</div>
))
) : (
<div className="text-muted-foreground text-xs text-center py-4">Your recent prompts will appear here</div>
)}
</div>
<div className="mt-auto border-t border-border pt-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Messages today</div>
<div className="text-foreground text-sm font-semibold mt-0.5">24</div>
</div>
<div className="flex-1">
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Tokens used</div>
<div className="text-foreground text-sm font-semibold mt-0.5">12.4k</div>
</div>
</div>
</div>
</div>
</div>
)
+2 -1
View File
@@ -20,6 +20,7 @@ import {
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
} from "lucide-react"
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
import { sanitizeSvg } from "@/lib/sanitize"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogClose,
@@ -1041,7 +1042,7 @@ const formatPreviewContent = (content: string) => {
{stickerData.emoji ? (
<span className="text-7xl block text-center">{stickerData.emoji}</span>
) : (
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
<div className="w-full" dangerouslySetInnerHTML={{ __html: sanitizeSvg(stickerData.url).replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
)}
</div>
) : voiceUrl ? (
-11
View File
@@ -1,11 +0,0 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get("session")?.value
if (!token) {
return NextResponse.json({ error: "No session" }, { status: 401 })
}
return NextResponse.json({ token })
}
+1 -1
View File
@@ -6,7 +6,7 @@ export async function POST() {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`,
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
},
})
} catch (error) {
+16 -7
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { query, transaction } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() {
@@ -18,13 +18,18 @@ export async function GET() {
u.email AS other_user_email,
u.phone AS other_user_phone,
u.avatar_url AS other_user_avatar_url,
(SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message_time,
lm.last_message_data->>'content' AS last_message,
(lm.last_message_data->>'created_at')::timestamptz AS last_message_time,
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
FROM conversations c
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
JOIN conversation_participants cp ON cp.conversation_id = c.id
JOIN users u ON u.id = cp.user_id AND u.id != $1
LEFT JOIN LATERAL (
SELECT jsonb_build_object('content', content, 'created_at', created_at) AS last_message_data
FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1
) lm ON true
WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
)
@@ -80,23 +85,27 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ conversationId: existing.rows[0].id })
}
const convResult = await query(
const result = await transaction(async (client) => {
const convResult = await client.query(
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
)
const conversationId = convResult.rows[0].id
await query(
await client.query(
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
[conversationId, user.id, userId],
)
const otherUser = await query(
const otherResult = await client.query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users WHERE id = $1`,
[userId],
)
const other = otherUser.rows[0]
return { conversationId, other: otherResult.rows[0] }
})
const { conversationId, other } = result
return NextResponse.json({
conversation: {
+111 -78
View File
@@ -48,74 +48,22 @@ function stageToStatus(name: string): string {
}
}
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
const result = await query(
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
l.notes, l.assigned_to, l.score,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
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.deleted_at IS NULL
AND l.created_at >= $1 AND l.created_at <= $2
${isAdmin ? "" : "AND l.assigned_to = $3"}
ORDER BY l.created_at DESC`,
isAdmin
? [start.toISOString(), end.toISOString()]
: [start.toISOString(), end.toISOString(), userId]
)
return result.rows.map((r: any) => ({
...r,
status: stageToStatus(r.stage_name),
}))
}
function countStatuses(leads: any[]) {
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
leads.forEach((l: any) => {
const s = l.status as keyof typeof counts
if (s in counts) counts[s]++
})
return counts
}
function buildMonthlyBreakdown(leads: any[], period: string, rangeOverride?: { start: Date; end: Date }) {
const { start, end } = rangeOverride || getPeriodDateRange(period)
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
const current = new Date(start)
const isMonthly = period === "6months" || period === "12months"
while (current <= end) {
const label = isMonthly
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
const ps = new Date(current)
const pe = isMonthly
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
const inPeriod = leads.filter((l: any) => {
const d = new Date(l.created_at)
return d >= ps && d <= pe
})
const counts = countStatuses(inPeriod)
result.push({ label, total: inPeriod.length, ...counts })
if (isMonthly) current.setMonth(current.getMonth() + 1)
else current.setDate(current.getDate() + 1)
}
return result
}
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
const pct = Math.round(((current - previous) / previous) * 100)
return { pct: Math.abs(pct), up: pct >= 0 }
}
const stageStatusSql = `
CASE
WHEN ls.name = 'New' THEN 'open'
WHEN ls.name = 'Contacted' THEN 'contacted'
WHEN ls.name IN ('Qualified', 'Interested', 'Demo Scheduled', 'Negotiation') THEN 'pending'
WHEN ls.name = 'Closed Won' THEN 'closed'
WHEN ls.name = 'Closed Lost' THEN 'ignored'
ELSE 'open'
END`
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -138,19 +86,107 @@ export async function GET(request: NextRequest) {
prevRange = getPreviousPeriodRange(period, start)
}
const [currentLeads, prevLeads] = await Promise.all([
fetchLeadsInRange(start, end, user.id, isAdmin),
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
])
const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
const currentCounts = countStatuses(currentLeads)
const prevCounts = countStatuses(prevLeads)
// Status counts for current period (SQL aggregation)
const countSql = `
SELECT ${stageStatusSql} AS status, COUNT(*) AS count
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
WHERE l.deleted_at IS NULL
AND l.created_at >= $1 AND l.created_at <= $2
${ownerFilter}
GROUP BY ${stageStatusSql}`
const totalLeads = currentLeads.length
const currentCountRows = await query(
countSql,
isAdmin
? [start.toISOString(), end.toISOString()]
: [start.toISOString(), end.toISOString(), user.id],
)
const prevCountRows = await query(
countSql,
isAdmin
? [prevRange.start.toISOString(), prevRange.end.toISOString()]
: [prevRange.start.toISOString(), prevRange.end.toISOString(), user.id],
)
function rowsToCounts(rows: any[]) {
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
for (const r of rows) {
const s = r.status as keyof typeof counts
if (s in counts) counts[s] = parseInt(r.count, 10)
}
return counts
}
const currentCounts = rowsToCounts(currentCountRows.rows)
const prevCounts = rowsToCounts(prevCountRows.rows)
const totalLeads = currentCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
const prevTotal = prevCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
const closedLeads = currentCounts.closed
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
const mappedLeads = currentLeads.map((r: any) => ({
// Monthly/weekly breakdown via date_trunc
const isMonthly = period === "6months" || period === "12months"
const truncUnit = isMonthly ? "month" : "day"
const breakdownSql = `
SELECT DATE_TRUNC($1, l.created_at) AS period_start,
${stageStatusSql} AS status,
COUNT(*) AS count
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
WHERE l.deleted_at IS NULL
AND l.created_at >= $2 AND l.created_at <= $3
${isAdmin ? "" : "AND l.assigned_to = $4"}
GROUP BY DATE_TRUNC($1, l.created_at), ${stageStatusSql}
ORDER BY period_start ASC`
const breakdownParams = isAdmin
? [truncUnit, start.toISOString(), end.toISOString()]
: [truncUnit, start.toISOString(), end.toISOString(), user.id]
const breakdownResult = await query(breakdownSql, breakdownParams)
// Build monthly breakdown from aggregated data
const breakdownMap: Record<string, { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }> = {}
for (const r of breakdownResult.rows) {
const d = new Date(r.period_start)
const label = isMonthly
? d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
if (!breakdownMap[label]) {
breakdownMap[label] = { label, total: 0, open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
}
const count = parseInt(r.count, 10)
breakdownMap[label].total += count
const statusKey = r.status as 'open' | 'contacted' | 'pending' | 'closed' | 'ignored'
breakdownMap[label][statusKey] = count
}
const monthlyBreakdown = Object.values(breakdownMap)
// Recent 10 leads
const recentSql = `
SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
l.notes, l.assigned_to, l.score,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
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.deleted_at IS NULL
AND l.created_at >= $1 AND l.created_at <= $2
${ownerFilter}
ORDER BY l.created_at DESC
LIMIT 10`
const recentResult = await query(
recentSql,
isAdmin
? [start.toISOString(), end.toISOString()]
: [start.toISOString(), end.toISOString(), user.id],
)
const recentLeads = recentResult.rows.map((r: any) => ({
id: r.id,
companyName: r.company_name || "",
contactName: r.contact_name,
@@ -158,7 +194,7 @@ export async function GET(request: NextRequest) {
phone: r.phone || "",
source: "",
description: r.notes || "",
status: r.status,
status: stageToStatus(r.stage_name),
assignedUserId: r.assigned_to,
assignedUser: r.assigned_to ? {
id: r.user_id,
@@ -170,17 +206,14 @@ export async function GET(request: NextRequest) {
updatedAt: r.updated_at,
}))
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined)
const trends = {
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
totalLeads: computeTrend(totalLeads, prevTotal),
openLeads: computeTrend(currentCounts.open, prevCounts.open),
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
conversionRate: computeTrend(conversionRate,
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
prevTotal > 0 ? Math.round((prevCounts.closed / prevTotal) * 100) : 0),
}
const stats = {
@@ -192,9 +225,9 @@ export async function GET(request: NextRequest) {
ignoredLeads: currentCounts.ignored,
conversionRate,
monthlyBreakdown,
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
leadsPerMonth: monthlyBreakdown.map((m) => ({ label: m.label, leads: m.total, closed: m.closed })),
trends,
recentLeads: mappedLeads.slice(0, 10),
recentLeads,
statusDistribution: [
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
+63 -77
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { query, transaction } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email"
export async function GET(request: NextRequest) {
@@ -119,87 +119,85 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
}
const result = await query(
// Single transaction for all DB operations
const result = await transaction(async (client) => {
// 1. Insert event
const ins = await client.query(
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
[
user.id,
participantId || null,
developerId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "website_creation",
startTime || null,
user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
title, description || null, eventType || "website_creation", startTime || null,
eventType === "website_creation" ? null : (endTime || null),
eventType === "website_creation" ? null : (durationMinutes || null),
clientName || null,
clientEmail || null,
clientPhone || null,
clientName || null, clientEmail || null, clientPhone || null,
],
user.id,
)
const r = ins.rows[0]
const r = result.rows[0]
// Auto-update lead to "pending" when a website creation event is created
// 2. Auto-update lead stage if website creation event
if (r.event_type === "website_creation" && leadId) {
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
if (stageResult.rows.length > 0) {
await query(
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
await client.query(
"UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL",
[stageResult.rows[0].id, leadId],
)
}
}
// 3. Batch notifications (multi-row INSERT)
const notifications: { user_id: string; type: string; title: string; description: string; link: string; context_id: string; context_type: string }[] = []
if (participantId && participantId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
participantId,
"event_scheduled",
`Meeting scheduled: ${title}`,
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
notifications.push({
user_id: participantId, type: "event_scheduled",
title: `Meeting scheduled: ${title}`,
description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
})
}
// Notify developer
if (developerId && developerId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
developerId,
"event_scheduled",
`Project assigned: ${title}`,
`${user.firstName} ${user.lastName} assigned a project to you`,
`/calendar`,
r.id,
"scheduled_event",
],
notifications.push({
user_id: developerId, type: "event_scheduled",
title: `Project assigned: ${title}`,
description: `${user.firstName} ${user.lastName} assigned a project to you`,
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
})
}
if (notifications.length > 0) {
const placeholders = notifications.map((_, i) =>
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`
).join(", ")
const flatParams = notifications.flatMap(n => [n.user_id, n.type, n.title, n.description, n.link, n.context_id, n.context_type])
await client.query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) VALUES ${placeholders}`,
flatParams,
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
// 4. Fetch participant + developer in one query
const idsToFetch = [participantId, developerId].filter(Boolean) as string[]
let usersMap: Record<string, { id: string; first_name: string; last_name: string; email: string }> = {}
if (idsToFetch.length > 0) {
const userPlaceholders = idsToFetch.map((_, i) => `$${i + 1}`).join(", ")
const userRows = await client.query(
`SELECT id, first_name, last_name, email FROM users WHERE id IN (${userPlaceholders})`,
idsToFetch,
)
for (const row of userRows.rows) {
usersMap[row.id] = row
}
}
const developerResult = developerId ? await query(
`SELECT id, first_name, last_name, email FROM users WHERE id = $1`,
[developerId],
) : null
const dev = developerResult?.rows[0] || null
return { r, usersMap }
}, user.id)
const { r, usersMap } = result
const participant = participantId ? usersMap[participantId] || null : null
const dev = developerId ? usersMap[developerId] || null : null
// Email sent asynchronously outside transaction (side effect)
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
@@ -215,28 +213,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_id,
leadId: r.lead_id, conversationId: r.conversation_id,
title: r.title, description: r.description, participantNotes: r.participant_notes,
eventType: r.event_type, startTime: r.start_time, endTime: r.end_time,
durationMinutes: r.duration_minutes, status: r.status, createdAt: r.created_at,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
participant: participant ? { id: participantId, name: `${participant.first_name} ${participant.last_name}`, email: participant.email } : null,
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at,
clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
},
}, { status: 201 })
} catch (error) {
+27 -3
View File
@@ -1,15 +1,38 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser, setSessionContext } from "@/lib/auth"
import { query } from "@/lib/db"
import crypto from "crypto"
export async function POST(request: NextRequest) {
try {
const { phone, token: clientToken } = await request.json()
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only SUPER_ADMIN can generate invites." }, { status: 403 })
}
const { phone } = await request.json()
if (!phone) {
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
}
const token = clientToken || crypto.randomBytes(24).toString("hex")
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
await setSessionContext(sessionUser.id, ipAddress)
const recentCount = await query(
`SELECT COUNT(*) AS cnt FROM invites WHERE created_at > NOW() - INTERVAL '1 hour'`,
)
if (parseInt(recentCount.rows[0]?.cnt || "0", 10) >= 10) {
return NextResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429 })
}
const token = crypto.randomBytes(24).toString("hex")
await query(
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
[token, phone],
@@ -19,7 +42,8 @@ export async function POST(request: NextRequest) {
const inviteUrl = `${origin}/join/${token}`
return NextResponse.json({ token, inviteUrl })
} catch {
} catch (error) {
console.error("Invite generation error:", error)
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
}
}
+46 -23
View File
@@ -43,47 +43,74 @@ export async function GET(request: NextRequest) {
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,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
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.deleted_at IS NULL`
const params: any[] = []
let paramIdx = 1
const conditions: string[] = ["l.deleted_at IS NULL"]
if (period !== "all") {
const range = getPeriodDateRange(period)
if (range) {
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
conditions.push(`l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`)
params.push(range.start.toISOString(), range.end.toISOString())
paramIdx += 2
}
}
if (search) {
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
conditions.push(`(l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`)
params.push(`%${search}%`)
paramIdx++
}
if (!isAdmin) {
sql += ` AND l.assigned_to = $${paramIdx}`
conditions.push(`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
// Push status filter into SQL (avoid client-side filtering after pagination)
const statusStageMap: Record<string, string[]> = {
open: ["New"],
contacted: ["Contacted"],
pending: ["Qualified", "Interested", "Demo Scheduled", "Negotiation"],
closed: ["Closed Won"],
ignored: ["Closed Lost"],
}
if (status !== "all") {
const stageNames = statusStageMap[status]
if (stageNames) {
const stagePlaceholders = stageNames.map((_, i) => `$${paramIdx + i}`)
conditions.push(`ls.name IN (${stagePlaceholders.join(", ")})`)
params.push(...stageNames)
paramIdx += stageNames.length
}
}
const result = await query(sql, params)
const whereClause = conditions.join(" AND ")
let leads = result.rows.map((r: any) => {
// Total count (same filters, no pagination)
const countResult = await query(
`SELECT COUNT(*) AS total FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE ${whereClause}`,
params.slice(0, paramIdx - 1),
)
const total = parseInt(countResult.rows[0]?.total || "0", 10)
// Data query with pagination
const dataSql = `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,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE ${whereClause}
ORDER BY l.created_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
const dataParams = [...params, limit, offset]
const result = await query(dataSql, dataParams)
const leads = result.rows.map((r: any) => {
const s = stageToStatus(r.stage_name)
return {
id: r.id,
@@ -106,11 +133,7 @@ export async function GET(request: NextRequest) {
}
})
if (status !== "all") {
leads = leads.filter((l: any) => l.status === status)
}
return NextResponse.json(leads)
return NextResponse.json({ leads, total })
} catch (error) {
console.error("Leads API error:", error)
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
+10
View File
@@ -1,7 +1,17 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (sessionUser.role !== "super_admin" && sessionUser.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const phone = request.nextUrl.searchParams.get("phone")
if (!phone) {
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
+11 -3
View File
@@ -36,6 +36,7 @@ function formatContent(text: string) {
interface ChatMessage {
role: "user" | "assistant"
content: string
ts?: number
gif?: {
url: string
previewUrl: string
@@ -101,15 +102,21 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [showGifPicker])
const _WEEK_MS = 604800000
useEffect(() => {
const saved = localStorage.getItem("ai-chat-messages")
if (saved) {
try {
const parsed = JSON.parse(saved)
let parsed = JSON.parse(saved)
if (Array.isArray(parsed) && parsed.length > 0) {
const cutoff = Date.now() - _WEEK_MS
parsed = parsed.filter((m: any) => !m.ts || m.ts > cutoff)
if (parsed.length > 0) {
setMessages(parsed)
loadedFromStorage.current = true
}
}
} catch {}
} else {
loadedFromStorage.current = false
@@ -118,7 +125,8 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
useEffect(() => {
if (messages.length > 0) {
localStorage.setItem("ai-chat-messages", JSON.stringify(messages))
const now = Date.now()
localStorage.setItem("ai-chat-messages", JSON.stringify(messages.map(m => ({ ...m, ts: m.ts || now }))))
}
}, [messages])
@@ -140,6 +148,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}, [])
useEffect(() => {
checkServer()
if (loadedFromStorage.current) return
fetch("/api/ai/jobs")
.then((r) => r.json())
@@ -169,7 +178,6 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
},
])
})
checkServer()
}, [checkServer])
useEffect(() => {
+3 -3
View File
@@ -59,10 +59,10 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
key={i}
type="button"
onClick={() => handleSelect(job)}
className="w-full text-left px-4 py-3 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-150 border-b border-border/20 last:border-0 border-l-2 border-l-transparent hover:border-l-primary/40"
className="w-full text-left px-4 py-3 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-150 border-b border-border/20 last:border-b-0 border-l-2 border-l-transparent hover:border-l-primary/40"
>
<div className="font-medium">{job.job_title}</div>
<div className="text-xs text-muted-foreground/60 mt-0.5">{job.industry} &mdash; {job.description}</div>
<div className="text-xs text-muted-foreground/60 mt-0.5">{job.industry} {job.description}</div>
</button>
))}
{jobs.length === 0 && !loading && (
@@ -76,7 +76,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
type="button"
onClick={() => onSearch(selected)}
disabled={searching}
className="w-full flex items-center justify-center gap-2 mt-3 bg-gradient-to-r from-[#f97316] to-[#ea580c] hover:from-[#ea580c] hover:to-[#dc2626] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-[#f97316]/20"
className="w-full flex items-center justify-center gap-2 mt-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed text-primary-foreground text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-primary/20"
>
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{searching ? "Searching..." : "Search Facebook"}
+3 -2
View File
@@ -8,6 +8,7 @@ import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react"
import { getStickerPacks, type StickerPack } from "@/data/stickers"
import { sanitizeSvg } from "@/lib/sanitize"
type Tab = "emoji" | "gif" | "sticker"
@@ -287,7 +288,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
>
{sticker.svg ? (
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
) : (
<span className="text-5xl">{sticker.emoji}</span>
)}
@@ -303,7 +304,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
if (!s) return null
return (
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(s.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
</button>
)
})}
+4 -3
View File
@@ -61,7 +61,8 @@ export function ThemeSettings() {
<Label
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",
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 cursor-pointer transition-all",
value === "dark" ? "hover:bg-muted" : "hover:bg-accent",
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)}
>
@@ -89,7 +90,7 @@ export function ThemeSettings() {
<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",
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-muted cursor-pointer transition-all",
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)}
>
@@ -117,7 +118,7 @@ export function ThemeSettings() {
<Label
htmlFor={`bg-${value}`}
className={cn(
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-muted cursor-pointer transition-all",
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)}
>
+2 -33
View File
@@ -1,21 +1,7 @@
import { SignJWT, jwtVerify } from "jose";
import bcrypt from "bcryptjs";
import { query } from "@/lib/db";
import { cookies } from "next/headers";
function randomHex(length: number): string {
const chars = "abcdef0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
const g = globalThis as typeof globalThis & { __JWT_RAW_SECRET?: string };
const RAW_SECRET = process.env.JWT_SECRET || (g.__JWT_RAW_SECRET ??= randomHex(32));
if (!RAW_SECRET) throw new Error("JWT_SECRET environment variable is required");
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
import { signToken, verifyToken } from "@/lib/jwt";
export const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5;
@@ -43,23 +29,6 @@ export async function comparePassword(
return bcrypt.compare(password, hash);
}
export async function signToken(payload: { userId: string; role: string }) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.setIssuedAt()
.sign(JWT_SECRET);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as { userId: string; role: string };
} catch {
return null;
}
}
export async function getUserByEmail(email: string) {
const result = await query(
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
@@ -221,7 +190,7 @@ export async function decryptPassword(encrypted: string): Promise<string | null>
}
export async function setSessionContext(userId: string, ip?: string) {
await query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
await query("SELECT set_session_user_context($1)", [userId]);
if (ip) {
await query("SELECT set_config('app.current_ip', $1, true)", [ip]);
}
+24 -1
View File
@@ -1,4 +1,4 @@
import { Pool } from "pg"
import { Pool, PoolClient } from "pg"
const dbUrl = process.env.DATABASE_URL
if (!dbUrl) {
@@ -9,6 +9,8 @@ const pool = new Pool({
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
statement_timeout: 30000,
idle_in_transaction_session_timeout: 10000,
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
})
@@ -29,4 +31,25 @@ export async function query(text: string, params?: unknown[], userId?: string) {
}
}
export async function transaction<T>(
callback: (client: PoolClient) => Promise<T>,
userId?: string,
): Promise<T> {
const client = await pool.connect()
try {
await client.query("BEGIN")
if (userId) {
await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId])
}
const result = await callback(client)
await client.query("COMMIT")
return result
} catch (e) {
await client.query("ROLLBACK").catch(() => {})
throw e
} finally {
client.release()
}
}
export default pool
+34
View File
@@ -0,0 +1,34 @@
import { jwtVerify, SignJWT } from "jose"
let encoded: Uint8Array
export function getJWTSecret(): Uint8Array {
if (!encoded) {
const raw = process.env.JWT_SECRET
if (!raw) {
throw new Error(
"JWT_SECRET environment variable is required. " +
"Generate one: openssl rand -hex 32",
)
}
encoded = new TextEncoder().encode(raw)
}
return encoded
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, getJWTSecret())
return payload as { userId: string; role: string }
} catch {
return null
}
}
export async function signToken(payload: { userId: string; role: string }) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.setIssuedAt()
.sign(getJWTSecret())
}
+6
View File
@@ -0,0 +1,6 @@
export function sanitizeSvg(svg: string): string {
return svg
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
.replace(/javascript\s*:/gi, "")
}
+20 -3
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { verifyToken } from "@/lib/jwt"
const publicRoutes = [
"/login",
@@ -12,6 +13,8 @@ const publicRoutes = [
"/fonts",
]
const SESSION_COOKIE = "session"
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
@@ -23,9 +26,9 @@ export async function middleware(request: NextRequest) {
return NextResponse.next()
}
const sessionCookie = request.cookies.get("session")?.value
const token = request.cookies.get(SESSION_COOKIE)?.value
if (!sessionCookie) {
if (!token) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
@@ -34,7 +37,21 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
const payload = await verifyToken(token)
if (!payload) {
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)
}
const requestHeaders = new Headers(request.headers)
requestHeaders.set("x-user-id", payload.userId)
requestHeaders.set("x-user-role", payload.role)
return NextResponse.next({ request: { headers: requestHeaders } })
}
export const config = {
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest"
const API_BASE = "http://localhost:3006"
async function loginAs(role: string) {
const credentials: Record<string, { email: string; password: string }> = {
admin: { email: "admin@coastit.co.za", password: "AdminAccess@2026" },
superadmin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" },
sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" },
dev: { email: "dev@coastit.co.za", password: "DevTesting@2026" },
}
const cred = credentials[role]
if (!cred) return null
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cred),
})
if (res.status !== 200) return null
return res.headers.get("set-cookie")?.split(";")[0]
}
describe("Bug Reports", () => {
it("allows any authenticated user to submit a bug report", async () => {
const cookie = await loginAs("dev")
if (!cookie) return
const res = await fetch(`${API_BASE}/api/bug-reports`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ title: "Test bug", description: "This is a test bug report" }),
})
expect(res.status).toBe(200)
})
it("rejects unauthenticated bug report submission", async () => {
const res = await fetch(`${API_BASE}/api/bug-reports`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Test", description: "Test" }),
})
expect(res.status).toBe(401)
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest"
// These tests require a running PostgreSQL database with the CRM schema.
// Run: npm run dev (starts the server on port 3006)
// Then: npx vitest run
const API_BASE = "http://localhost:3006"
async function loginAs(role: string) {
const credentials: Record<string, { email: string; password: string }> = {
admin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" },
sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" },
}
const cred = credentials[role] || credentials.admin
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cred),
})
if (res.status !== 200) return null
const setCookie = res.headers.get("set-cookie")
return setCookie?.split(";")[0] // return the raw cookie value
}
describe("Leads API", () => {
it("creates a lead when authenticated", async () => {
const cookie = await loginAs("sales")
if (!cookie) return // skip if DB not available
const res = await fetch(`${API_BASE}/api/leads`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({
company_name: "Test Company",
contact_name: "Test Contact",
email: "test@example.com",
}),
})
expect(res.status).toBe(200)
// Cleanup
const data = await res.json()
if (data?.id) {
await fetch(`${API_BASE}/api/leads/${data.id}`, {
method: "DELETE",
headers: { Cookie: cookie },
})
}
})
it("rejects unauthenticated lead creation", async () => {
const res = await fetch(`${API_BASE}/api/leads`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ company_name: "Test", contact_name: "Test", email: "test@test.com" }),
})
expect(res.status).toBe(401)
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest"
// These tests require a running PostgreSQL database with the CRM schema.
// Set DATABASE_URL env var or the default local connection will be used.
//
// Run: DATABASE_URL=postgresql://postgres:postgres@localhost:5432/crm_test npx vitest run
const API_BASE = "http://localhost:3006"
function skipIfNoDb() {
if (!process.env.CI && !process.env.DATABASE_URL?.includes("crm_test")) {
console.warn("Skipping DB-dependent tests. Set DATABASE_URL to a test database.")
return true
}
return false
}
describe("Login", () => {
it("returns 401 with wrong password", async () => {
if (skipIfNoDb()) return
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "superadmin@coastit.co.za", password: "wrong" }),
})
expect(res.status).toBe(401)
})
it("returns 400 with empty body", async () => {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
})
it("returns 401 for non-existent user", async () => {
if (skipIfNoDb()) return
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "nobody@example.com", password: "anything" }),
})
expect(res.status).toBe(401)
})
})
describe("Authorization", () => {
it("rejects unauthenticated requests to protected routes", async () => {
const res = await fetch(`${API_BASE}/api/leads`, { headers: { "Content-Type": "application/json" } })
expect(res.status).toBe(401)
})
it("rejects unauthenticated requests to dashboard", async () => {
const res = await fetch(`${API_BASE}/api/dashboard`, { headers: { "Content-Type": "application/json" } })
expect(res.status).toBe(401)
})
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest"
import { signToken, verifyToken } from "@/lib/jwt"
process.env.JWT_SECRET = "test-secret-that-is-at-least-32-chars-long-for-security"
describe("JWT", () => {
it("signs and verifies a valid token", async () => {
const token = await signToken({ userId: "user-1", role: "admin" })
expect(token).toBeTruthy()
expect(typeof token).toBe("string")
const payload = await verifyToken(token)
expect(payload).not.toBeNull()
expect(payload!.userId).toBe("user-1")
expect(payload!.role).toBe("admin")
})
it("rejects a tampered token", async () => {
const token = await signToken({ userId: "user-1", role: "admin" })
const tampered = token.slice(0, -5) + "XXXXX"
const payload = await verifyToken(tampered)
expect(payload).toBeNull()
})
it("rejects an expired token", async () => {
const { SignJWT } = await import("jose")
const { getJWTSecret } = await import("@/lib/jwt")
const expiredToken = await new SignJWT({ userId: "user-1", role: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("0s")
.sign(getJWTSecret())
const payload = await verifyToken(expiredToken)
expect(payload).toBeNull()
})
it("rejects a token with invalid signature", async () => {
const { SignJWT } = await import("jose")
const wrongSecret = new TextEncoder().encode("different-secret-key-for-signing-purposes-only")
const token = await new SignJWT({ userId: "user-1", role: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.setIssuedAt()
.sign(wrongSecret)
const payload = await verifyToken(token)
expect(payload).toBeNull()
})
it("returns null for empty token", async () => {
const payload = await verifyToken("")
expect(payload).toBeNull()
})
it("returns null for garbage token", async () => {
const payload = await verifyToken("this.is.not.a.jwt")
expect(payload).toBeNull()
})
})
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest"
import { sanitizeSvg } from "@/lib/sanitize"
describe("sanitizeSvg", () => {
it("strips script tags", () => {
const input = `<svg><script>alert('xss')</script><rect width="100" height="100"/></svg>`
const result = sanitizeSvg(input)
expect(result).not.toContain("script")
expect(result).toContain("<svg")
expect(result).toContain("<rect")
})
it("strips event handler attributes", () => {
const input = `<svg onload="alert(1)"><rect width="100" height="100"/></svg>`
const result = sanitizeSvg(input)
expect(result).not.toContain("onload")
expect(result).toContain("<svg")
expect(result).toContain("<rect")
})
it("strips onclick attributes", () => {
const input = `<svg><rect onclick="evil()" width="100" height="100"/></svg>`
const result = sanitizeSvg(input)
expect(result).not.toContain("onclick")
expect(result).toContain("<rect")
expect(result).toContain('width="100"')
})
it("removes javascript: URLs", () => {
const input = `<svg><a href="javascript:alert(1)">click</a></svg>`
const result = sanitizeSvg(input)
expect(result).not.toContain("javascript:")
})
it("passes through clean SVGs unchanged (except whitespace)", () => {
const input = `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`
const result = sanitizeSvg(input)
expect(result).toContain("viewBox")
expect(result).toContain("<circle")
expect(result).toContain('cx="12"')
})
})
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config"
import path from "path"
export default defineConfig({
test: {
globals: true,
environment: "node",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})