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
+35 -35
View File
@@ -12,6 +12,7 @@ import http from "node:http"
import fs from "node:fs" import fs from "node:fs"
import path from "node:path" import path from "node:path"
import { spawn } from "node:child_process" import { spawn } from "node:child_process"
import crypto from "node:crypto"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.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 HOST = process.env.AI_HOST || "0.0.0.0"
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434" const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
const MODEL = process.env.AI_MODEL || "llama3.2:3b" 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 DATABASE_URL = process.env.DATABASE_URL
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl") 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") 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)}` : ""}` const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
try { try {
const body = await new Promise((resolve, reject) => { 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 = "" let data = ""
res.on("data", (c) => data += c) res.on("data", (c) => data += c)
res.on("end", () => resolve(data)) res.on("end", () => { done = true; resolve(data) })
res.on("error", reject) res.on("error", (e) => { if (!done) { done = true; reject(e) } })
}) })
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) }) req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
req.on("error", reject) req.on("error", (e) => { if (!done) { done = true; reject(e) } })
req.end() req.end()
}) })
const data = JSON.parse(body) const data = JSON.parse(body)
return data return data
} catch (e) { } catch (e) {
console.error("scrapeFacebook error:", e.message)
return null 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`, { const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(60000),
body: JSON.stringify({ body: JSON.stringify({
model: MODEL, model: MODEL,
messages: [ messages: [
@@ -313,18 +320,20 @@ const server = http.createServer(async (req, res) => {
if (req.method === "GET" && pathname === "/status") { if (req.method === "GET" && pathname === "/status") {
const { default: http } = await import("http") const { default: http } = await import("http")
const results = { ai: true } const results = { ai: true }
// Check scraper (port 3008) // Check scraper
try { try {
await new Promise((resolve, reject) => { 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) r.on("error", reject)
}) })
results.scraper = true results.scraper = true
} catch { results.scraper = false } } catch { results.scraper = false }
// Check frontend (port 3006) // Check frontend
try { try {
await new Promise((resolve, reject) => { 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) r.on("error", reject)
}) })
results.frontend = true results.frontend = true
@@ -368,8 +377,8 @@ const server = http.createServer(async (req, res) => {
let selectedBrowser = process.env.SELECTED_BROWSER || "" let selectedBrowser = process.env.SELECTED_BROWSER || ""
try { try {
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) }) await fetch(`${SCRAPER_URL}/health`, { signal: AbortSignal.timeout(2000) })
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json() const profiles = await (await fetch(`${SCRAPER_URL}/setup/profile`, { signal: AbortSignal.timeout(5000) })).json()
for (const [b, p] of Object.entries(profiles)) { for (const [b, p] of Object.entries(profiles)) {
if (p) browsers[b] = { path: p } 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) const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
for (const [b, v] of detectedList) { for (const [b, v] of detectedList) {
try { 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" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ browser: b, profile_path: v.path }), body: JSON.stringify({ browser: b, profile_path: v.path }),
signal: AbortSignal.timeout(20000), signal: AbortSignal.timeout(20000),
@@ -536,38 +545,29 @@ const server = http.createServer(async (req, res) => {
// Accepts { message, user_id?, user_role? } and returns AI response. // Accepts { message, user_id?, user_role? } and returns AI response.
// user_role must be "sales", "admin", or "super_admin" if provided. // user_role must be "sales", "admin", or "super_admin" if provided.
if (req.method === "POST" && pathname === "/ai/chat") { if (req.method === "POST" && pathname === "/ai/chat") {
const startTime = Date.now()
const chunks = [] const chunks = []
req.on("data", c => chunks.push(c)) req.on("data", c => chunks.push(c))
req.on("end", () => { req.on("end", async () => {
const rawBody = Buffer.concat(chunks).toString()
try { try {
const rawBody = Buffer.concat(chunks).toString()
const body = JSON.parse(rawBody) const body = JSON.parse(rawBody)
processRequest(req, res, body, startTime) const { message, user_id, user_role } = body
} catch { if (!message) {
sendJSON(res, 400, { error: "Invalid JSON" }) 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")
sendJSON(res, 200, { response })
} catch (e) {
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
} }
}) })
return 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 })
}
// 404 fallback // 404 fallback
sendJSON(res, 404, { error: "Not found" }) sendJSON(res, 404, { error: "Not found" })
} catch (err) { } catch (err) {
+509 -207
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
CORSMiddleware, 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_methods=["POST"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -372,6 +372,7 @@ OFFER_PATTERNS = [
r"\bmy\s+portfolio\b", r"\bmy\s+portfolio\b",
r"\breasonable\s+(price|pricing|rate)\b", r"\breasonable\s+(price|pricing|rate)\b",
r"\bwhatsapp\s+(me|us|at)\b", r"\bwhatsapp\s+(me|us|at)\b",
r"\bwhatsapp\b",
r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b", r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b",
r"\bhelp\s+your\s+business\b", r"\bhelp\s+your\s+business\b",
r"\bi\s+am\s+a\s+(web|freelance|designer|developer)\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"\bselling\s+(my|a|the|this)\b",
r"\bpremium\b", r"\bpremium\b",
r"\bi'm\s+selling\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 = [ REQUEST_PATTERNS = [
@@ -625,13 +666,106 @@ TUTORING_SEARCHES = [
"tutor near me for", "tutor near me for",
] ]
def _search_list_for_query(query: str) -> list[str]: # ── South African Multi-Language Queries ──────────────────────────────
"""Pick the appropriate search query pool based on the search term.""" # 4 SA languages grouped for phase-based scanning:
tl = query.lower() # Phase 1: English → Phase 2: Afrikaans → Phase 3: isiXhosa → Phase 4: English (final sweep)
tutoring_terms = ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child", "math", "english", "science", "exam", "homeschool", "coding", "programming", "piano", "reading"] # Each language group has dedicated queries per category.
if any(t in tl for t in tutoring_terms):
return TUTORING_SEARCHES SA_WEBSITE_QUERIES = {
return FB_SEARCHES "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 = [ VIEWPORTS = [
{'width': 1280, 'height': 800}, {'width': 1280, 'height': 800},
@@ -723,7 +857,7 @@ def _parse_fb_date(block: list[str]) -> str:
return datetime.now().strftime('%Y-%m-%d') 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.""" """Check if date is within max_days from now. Empty/unparseable = keep."""
if not date_str: if not date_str:
return True return True
@@ -748,7 +882,7 @@ def _clean_fb_text(text: str) -> str:
return '\n'.join(cleaned_lines) return '\n'.join(cleaned_lines)
# Words that should never be treated as person names # 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 ────────────────────────────────────────────────── # ── Post Extraction ──────────────────────────────────────────────────
# Two strategies for extracting posts from page content: # 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 # 2. _extract_posts_from_text — fallback that parses raw page text line by line
# Both apply dedup (seen_texts), offer filtering, and request scoring. # 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]: def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
posts = [] posts = []
seen_texts = set() seen_texts = set()
now = datetime.utcnow()
for el in elements: 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', '') raw_text = el.get('text', '')
if len(raw_text) < 40: if len(raw_text) < 40:
continue continue
@@ -773,13 +919,23 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
if dekey in seen_texts: if dekey in seen_texts:
continue continue
seen_texts.add(dekey) 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 request_score = 2 if is_request(text) else 0
post_url = el.get('url') or base_url post_url = el.get('url') or base_url
# Prefer JS-extracted date, fall back to text parsing # Prefer JS-extracted date, fall back to text parsing
js_date = (el.get('date') or '').strip() js_date = (el.get('date') or '').strip()
if js_date: if js_date:
# Try ISO datetime from <time datetime>
try: try:
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00')) dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
date_str = dt.strftime('%Y-%m-%d') date_str = dt.strftime('%Y-%m-%d')
@@ -883,7 +1039,6 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
if name: if name:
p['author'] = name p['author'] = name
# Remove group posts: any post whose text looks like it came from a group # 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 = [] filtered = []
for p in posts: for p in posts:
t = (p.get('title') or p.get('content') or '') 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 continue
lower = t.lower() lower = t.lower()
skip = False skip = False
for suff in _group_suffixes: for suff in _GROUP_SUFFIXES:
if suff.lower() in lower: if suff.lower() in lower:
skip = True skip = True
break break
if skip: if skip:
continue continue
filtered.append(p) 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 ────────────────────────────────── # ── Human-like Behavior Simulation ──────────────────────────────────
# These functions add random delays, mouse movements, and scroll patterns # 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('''() => { return await page.evaluate('''() => {
const results = []; const results = [];
const seenTexts = new Set(); 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/"]'); 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) { for (const a of anchors) {
const h = a.getAttribute('href') || ''; const h = a.getAttribute('href') || '';
@@ -966,15 +1145,22 @@ async def _get_article_elements(page) -> list[dict]:
const key = txt.substring(0, 80); const key = txt.substring(0, 80);
if (seenTexts.has(key)) continue; if (seenTexts.has(key)) continue;
seenTexts.add(key); seenTexts.add(key);
const isGroup = h.includes('/groups/');
let author = ''; let author = '';
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]'); const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) author = (nameEl.innerText || '').trim(); if (nameEl) author = (nameEl.innerText || '').trim();
if (!author || author.length > 60 || author.length < 2) author = ''; if (!author || author.length > 60 || author.length < 2) author = '';
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h; let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
let date = ''; let date = '';
let ts = 0;
const timeEl = cell.querySelector('time'); const timeEl = cell.querySelector('time');
if (timeEl) date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || ''; if (timeEl) {
results.push({ text: txt, author, url: postUrl, date }); 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; return results;
}''') }''')
@@ -1119,7 +1305,9 @@ async def search_facebook(page, context, query: str):
p['author'] = '' p['author'] = ''
# Final filter: strip any remaining group posts # Final filter: strip any remaining group posts
posts = [p for p in posts if not ( 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: except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e) logger.warning("Facebook search '%s' failed: %s", query, e)
@@ -1159,6 +1347,94 @@ def cleanup_chrome():
pass 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 ──────────────────────────────────────────── # ── Main Scrape Dispatcher ────────────────────────────────────────────
# scrape_facebook() is the main entry point. It: # scrape_facebook() is the main entry point. It:
# 1. Resolves the browser profile path (from SELECTED_BROWSER env var or auto-detect) # 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 # Firefox path
if browser_type == "firefox": if browser_type == "firefox":
result = await _scrape_with_firefox(effective_path, force, query) result = await _scrape_with_firefox(effective_path, force, query)
if result.get("success") or not result.get("flagged"): if result.get("success"):
return result return result
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown")) logger.warning("Firefox failed (reason: %s), trying Agent", result.get("flag_reason") or result.get("error", "unknown"))
return await _scrape_with_agent(force) return await _scrape_with_agent(force, query)
# Chromium-based (chrome / opera / edge) # Chromium-based (chrome / opera / edge)
result = await _scrape_with_chromium(effective_path, browser_type, force, query) 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 return result
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown")) 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) return await _scrape_with_agent(force, query)
# ── Firefox Scraper ────────────────────────────────────────────────── # ── 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( context = await pw.firefox.launch_persistent_context(
user_data_dir=profile_dir, user_data_dir=profile_dir,
headless=True, headless=True,
viewport=random.choice(VIEWPORTS),
firefox_user_prefs={ firefox_user_prefs={
"dom.webdriver.enabled": False, "dom.webdriver.enabled": False,
"dom.webdriver.timeout": 0, "dom.webdriver.timeout": 0,
@@ -1265,74 +1542,35 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
except Exception: except Exception:
logger.warning("Google navigation failed, trying Facebook directly") logger.warning("Google navigation failed, trying Facebook directly")
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000) try:
await page.wait_for_timeout(random.randint(3000, 8000)) await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
url = page.url url = page.url
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else '' page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text) det = check_detection_signals(url, page_text)
if det or '/login' in url.lower(): if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page") 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"}
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)) await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25: if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)") await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000)) await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2)) await human_scroll(page, steps=random.randint(1, 2))
if not force and random.random() < 0.3: if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000)) await page.wait_for_timeout(random.randint(8000, 20000))
await context.close() return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = [] leads = await _run_phases(page, context, query)
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: return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
await page.wait_for_timeout(random.randint(3000, 10000)) finally:
try:
await context.close() await context.close()
except Exception:
seen = set() pass
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)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e: except Exception as e:
logger.error("Firefox scrape failed: %s", 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( launch_kwargs = dict(
user_data_dir=profile_dir, user_data_dir=profile_dir,
headless=True, headless=True,
viewport=random.choice(VIEWPORTS),
args=CHROME_LAUNCH_ARGS, args=CHROME_LAUNCH_ARGS,
) )
if channel: if channel:
@@ -1400,72 +1639,35 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
except Exception: except Exception:
logger.warning("Google navigation failed, trying Facebook directly") logger.warning("Google navigation failed, trying Facebook directly")
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000) try:
await page.wait_for_timeout(random.randint(3000, 8000)) await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
url = page.url url = page.url
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else '' page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text) det = check_detection_signals(url, page_text)
if det or '/login' in url.lower(): if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page") 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"}
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)) await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25: if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)") await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000)) await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2)) await human_scroll(page, steps=random.randint(1, 2))
if not force and random.random() < 0.3: if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000)) await page.wait_for_timeout(random.randint(8000, 20000))
await context.close() return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = [] leads = await _run_phases(page, context, query)
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: return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
await page.wait_for_timeout(random.randint(3000, 10000)) finally:
try:
await context.close() await context.close()
except Exception:
seen = set() pass
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)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e: except Exception as e:
logger.error("%s scrape failed: %s", browser, 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. # Uses Chromium headless with the same launch args as _scrape_with_chromium.
# The Agent is prompted to extract structured post data and return JSON. # 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).""" """Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
cleanup_chrome() cleanup_chrome()
profile_dir = None profile_dir = None
@@ -1504,7 +1706,14 @@ async def _scrape_with_agent(force: bool = False) -> dict:
await browser.start() await browser.start()
all_posts = [] 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( agent = _make_agent(
task=f"""You are logged into Facebook. Do the following: task=f"""You are logged into Facebook. Do the following:
1. Navigate to facebook.com and make sure you are on the homepage 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 text content
- The post URL (if visible) - The post URL (if visible)
- The post date - 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) 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.""", 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) seen.add(key)
deduped.append(p) deduped.append(p)
# Filter to last 3 days only # Filter to last 2 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)] deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 2)]
leads = deduped[:20] leads = deduped[:20]
if leads: 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} return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e: except Exception as e:
@@ -1584,22 +1793,37 @@ async def ask_ollama(prompt: str) -> str:
data = r.json() data = r.json()
return data["message"]["content"] 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: if not results:
return [] return []
# ── 1. AI classification ───────────────────────────────────────── # ── 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. prompt = f"""Classify each post as LEAD or NOT.
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them. LEAD = {lead_desc}.
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 examples: {lead_examples}
NOT LEAD: 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 - Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
- Recruiting employees, hiring staff, looking for business partners - Recruiting employees, hiring staff, looking for business partners
- Selling products, promoting services, affiliate offers - 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): For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))} {chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
@@ -1624,32 +1848,70 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
except Exception as e: except Exception as e:
logger.warning("AI classification failed: %s", e) logger.warning("AI classification failed: %s", e)
# ── 2. Keyword fallback (always runs) ──────────────────────────── # ── 2. Keyword supplement (never overrides AI, only adds missing leads) ──
web_terms = [ if tutoring:
"website", "web design", "web develop", "web dev", target_terms = [
"web designer", "web developer", "tutor", "tutoring", "tutor for", "private tutor",
"build my website", "build a website", "create a website", "math tutor", "english tutor", "reading tutor",
"landing page", "wordpress", "ecommerce", "science tutor", "online tutor", "home tutor",
"my website", "business website", "lessons for", "lessons for my", "piano lessons",
"site for my", "site for my business", "swimming lessons", "music lessons",
"new website", "redesign my website", "help with homework", "homework help",
"help with my website", "update my website", "teacher for", "teacher for my",
"make a website", "make my website", "need help learning", "need help with",
"website for my", "exam prep", "exam preparation",
"online store", "online shop", "homeschool", "homeschool tutor",
"build my site", "build a site", "tuition",
"set up a website", "set up my website", "coding for my", "programming for my",
"custom website", "looking for a tutor", "need a tutor",
"shopify", "tutor needed", "tutoring for",
"my site", "private lessons", "private tuition",
"webpage", "web page", "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",
"landing page", "wordpress", "ecommerce",
"my website", "business website",
"site for my", "site for my business",
"new website", "redesign my website",
"help with my website", "update my website",
"make a website", "make my website",
"website for my",
"online store", "online shop",
"build my site", "build a site",
"set up a website", "set up my website",
"custom website",
"shopify",
"my site",
"webpage", "web page",
"who can build", "who can design",
"create my website", "create my site",
]
offer_reject_tutor = []
request_terms = [ request_terms = [
"looking for", "need a", "need an", "looking to", "looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone", "need someone", "hire a", "want someone",
"need help with", "would like", "build me", "need help with", "would like", "build me",
"design my", "make me a", "create my", "design my", "make me a", "create my",
"looking", "need", "want", "help",
"who can", "i need", "who can", "i need",
"recommend", "anyone know", "anyone recommend", "recommend", "anyone know", "anyone recommend",
"know a", "know any", "recommendation", "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', 'whatsapp me', 'looking for a business', 'looking for client',
'help your business', 'i am a web', 'contact me', 'help your business', 'i am a web', 'contact me',
'we offer web', 'we provide web', 'we offer web', 'we provide web',
'take the quiz', 'homeschool', 'your home tutor', 'take the quiz',
'link in bio', 'apply now', 'get started', 'link in bio', 'apply now', 'get started',
'for only', 'low price', 'hit me up', 'for only', 'low price', 'hit me up',
'send me a message', 'i do website', 'we do website', 'send me a message', 'i do website', 'we do website',
'we do web', 'i do web', 'we do web', 'i do web',
'website designer / web developer', 'website & software creators', 'website designer / web developer', 'website & software creators',
'website builders for small businesses', 'australia web designers', 'website builders for small businesses',
'south africa', 'wix website design', 'wix website design',
'for sale', 'selling my', 'premium', 'for sale', 'selling my', 'premium',
'i\'m selling', 'i\'m offering', 'we\'re offering', 'i\'m selling', 'i\'m offering', 'we\'re offering',
'free ecommerce', 'free website design', 'free ecommerce', 'free website design',
'starting a', 'looking for a few businesses', '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: for r in results:
t = r['title'].lower() t = (r.get('title') or r.get('content') or '').lower()
has_web = any(kw in t for kw in web_terms) has_target = any(kw in t for kw in target_terms)
has_request = any(kw in t for kw in request_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 continue
if any(kw in t for kw in offer_reject): if any(kw in t for kw in offer_reject):
continue continue
if any(kw in t for kw in offer_reject_tutor):
continue
keyword_leads.append(r) keyword_leads.append(r)
# ── 3. Merge: prefer AI leads, supplement with keywords to reach 5 ── # ── 3. Merge: prefer AI leads, supplement with keywords ──
seen_titles: set[int] = set() seen_titles: set[str] = set()
merged: list[dict] = [] merged: list[dict] = []
for r in ai_leads + keyword_leads: for r in ai_leads + keyword_leads:
key = hash(r.get('title', '')) key = (r.get('title') or '').strip()[:200]
if key not in seen_titles: if key and key not in seen_titles:
seen_titles.add(key) seen_titles.add(key)
merged.append(r) merged.append(r)
# Final sweep: strip any remaining offers or group posts from merged # 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)] merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
# 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
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results)) 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] return merged[:10]
+128 -26
View File
@@ -1,40 +1,142 @@
# AI Sales Assistant — Self-Improvement Instructions # CRM AI Sales Assistant — Self-Knowledge
## Purpose ## Identity
This file contains the AI's own configuration, knowledge, and improvement rules. You are the CRM AI Sales Assistant for Coast IT CRM.
The AI can read and modify this file to update its behavior at runtime. 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 ## Architecture
- Always respond in English ```
- Keep responses under 300 words unless asked for detail User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
- Use bullet points for lists
- Be direct and actionable — no fluff PostgreSQL (conversations)
- 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
## Knowledge Base Python Scraper (:3008) — Facebook scraping via Playwright
### Sales Tips ```
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 - Cold emails should be under 150 words
- Follow up within 48 hours - Follow up within 48 hours
- Personalise every outreach with the prospect's name and company - Personalise every outreach with the prospect's name and company
- Use open-ended questions in discovery calls - Use open-ended questions in discovery calls
- Always ask for the next step before ending a call - 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 - Developers respond best to technical value props
- Marketing managers care about ROI and metrics - Marketing managers care about ROI and metrics
- C-level executives want brevity and business impact - 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 ## Improvement Log
Track changes made by the AI to improve itself: - (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
- (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.
@@ -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) ===' \echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
\i 020_fixes.sql \i 020_fixes.sql
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
\i 021_performance_indexes.sql
\echo '=== Migration Complete ===' \echo '=== Migration Complete ==='
COMMIT; COMMIT;
+2 -2
View File
@@ -1,6 +1,6 @@
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from "eslint-config-next/core-web-vitals.js";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript.js";
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...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 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": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:signaling": "node signaling-server.mjs", "dev:signaling": "node signaling-server.mjs",
"dev:open": "node scripts/open-browser.mjs", "dev:open": "node scripts/open-browser.mjs",
"dev:repair": "node scripts/code-repair-agent.mjs --watch", "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: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:next": "next dev -p 3006", "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:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs", "dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py", "dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
"setup": "node scripts/setup.mjs", "setup": "node scripts/setup.mjs",
"setup:self-heal": "node scripts/setup.mjs --self-heal", "migrate": "node scripts/run-migrations.mjs",
"repair": "node scripts/code-repair-agent.mjs", "db:migrate": "npm run migrate",
"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",
"build": "next build", "build": "next build",
"start": "next start -p 3006", "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": { "dependencies": {
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.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-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
@@ -48,7 +50,6 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devenv": "^1.0.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jose": "^6.2.3", "jose": "^6.2.3",
@@ -69,17 +70,20 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@next/swc-win32-x64-msvc": "^15.0.4",
"@types/nodemailer": "^8.0.1", "@types/node": "20.19.43",
"@types/nodemailer": "8.0.1",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^18", "@types/react": "18.3.31",
"@types/react-dom": "^18", "@types/react-dom": "18.3.7",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"devenv": "1.0.1",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.0.4", "eslint-config-next": "15.0.4",
"maildev": "^2.2.1", "maildev": "^2.2.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "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 ## Identity
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio). You are the CRM AI Sales Assistant for Coast IT CRM.
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b). 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. 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 ## Capabilities
- Give sales tips and strategies per job category - 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 - Handle objections with proven rebuttals
- Analyse prospect behaviour and suggest next steps - Analyse prospect behaviour and suggest next steps
- Remember past conversations via PostgreSQL (`ai_conversations` table) - 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` - Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
## Architecture ## Facebook Scraper
``` The scraper lives at `browser-use-service/main.py` port 3008.
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 (in code but not yet active) ### How It Works
The Rust binary has proxy + user-agent rotation ready in `main.rs`. 1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
To activate: call `run_facebook_scraper()` from the main loop. 2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
Proxies and user agents are defined as constants at the top of `main.rs`. 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 ### Lead Categories
1. You notice a gap in your knowledge or a pattern in user questions Two categories, selectable when starting a scrape:
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement **Website Creation:**
- `content`: optional full replacement of ai.md - Target: people explicitly REQUESTING a website built/designed/created for them
3. The improvement is logged and loaded into the next system prompt - Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
4. You can also create entries here in `rust-ai/src/AI.md` at the code level - 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 ## Response Rules
- Be direct and actionable — no fluff, no AI disclaimers - 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 - Never mention being an AI or language model
- If you don't know something, say so honestly - If you don't know something, say so honestly
- Prioritise the user's role: salespeople need speed, admins need control - 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::{ use axum::{
extract::State, extract::State,
http::{HeaderMap, Method, StatusCode}, http::{HeaderMap, HeaderValue, Method, StatusCode},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
@@ -482,11 +482,12 @@ async fn main() {
rate_limiter: RateLimiter::new(30, 60), 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() let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list([ .allow_origin(AllowOrigin::list(cors_origins))
"http://localhost:3006".parse().unwrap(),
"http://127.0.0.1:3006".parse().unwrap(),
]))
.allow_methods([Method::GET, Method::POST]) .allow_methods([Method::GET, Method::POST])
.allow_headers(Any); .allow_headers(Any);
@@ -506,7 +507,7 @@ async fn main() {
let bg_leads = lead_store.clone(); let bg_leads = lead_store.clone();
let bg_db = state.db.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 { tokio::spawn(async move {
let client = match reqwest::Client::builder() let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(300)) .timeout(Duration::from_secs(300))
+3 -4
View File
@@ -2,10 +2,9 @@
// Verifies all packages listed in package.json are installed. // Verifies all packages listed in package.json are installed.
// Auto-runs npm install if any are missing — zero manual setup steps. // 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 { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
import { execSync } from "node:child_process" import { execSync } from "node:child_process"
import { platform } from "node:os" import { platform } from "node:os"
@@ -15,10 +14,10 @@ const root = resolve(__dirname, "..")
try { try {
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies } const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
const require = createRequire(import.meta.url)
const missing = Object.keys(allDeps).filter(name => { 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) { if (missing.length > 0) {
+3 -63
View File
@@ -177,8 +177,6 @@ function parseMissingModules(buildOutput) {
// ── Main ─────────────────────────────────────────────────────────── // ── Main ───────────────────────────────────────────────────────────
const args = process.argv.slice(2)
const doSelfHeal = args.includes("--self-heal") || args.includes("-s")
const PY = detectPython() const PY = detectPython()
const PIP = detectPip(PY) const PIP = detectPip(PY)
@@ -202,69 +200,11 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── .env.local already exists, skipping ──") console.log("\n── .env.local already exists, skipping ──")
} }
// 5. Database migrations (auto-applies on fresh install) // 5. Final summary
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
console.log("\n── Final Steps ──") console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'") 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(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
console.log(" 3. Edit .env.local with your settings") 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! ===") console.log("\n=== Setup complete! ===")
+5 -2
View File
@@ -4,8 +4,11 @@ import { SignJWT, jwtVerify } from "jose"
import pg from "pg" import pg from "pg"
const PORT = process.env.SIGNALING_PORT || 3007 const PORT = process.env.SIGNALING_PORT || 3007
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026") const rawSecret = process.env.JWT_SECRET
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm" 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 }) 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 { JobSelector } from "@/components/ai/job-selector"
import { Bot, ChevronRight } from "lucide-react" 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() { export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null) const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([]) const [recentPrompts, setRecentPrompts] = useState<string[]>([])
@@ -25,7 +17,7 @@ export default function AIAssistantPage() {
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => { const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
setSearching(true) 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...`) aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`)
const controller = new AbortController() 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)...") aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...")
}, 45000) }, 45000)
const scrapBase = process.env.NEXT_PUBLIC_SCRAPER_URL || "http://localhost:3008"
try { 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(timeoutId)
clearTimeout(statusId) clearTimeout(statusId)
const data = await res.json() const data = await res.json()
if (data.success && data.leads?.length > 0) { if (data.success && data.leads?.length > 0) {
const leadsText = data.leads.map((lead: any, i: number) => const leadLines = data.leads
`**${i + 1}.** ${lead.author || "Unknown"}\n> ${(lead.content || "").slice(0, 300)}\n> 🔗 ${lead.url || "(no link available)"}` .filter(Boolean)
).join("\n\n") .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}`) aiChatRef.current?.addAssistantMessage(`✅ Found **${data.leads.length}** leads:\n\n${leadsText}`)
} else { } else {
const reason = data.error || data.flag_reason || "No leads found this time" 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) => { const handleRecentPromptClick = useCallback((prompt: string) => {
aiChatRef.current?.fillInput(prompt) aiChatRef.current?.fillInput(prompt)
}, []) }, [])
@@ -123,53 +116,7 @@ export default function AIAssistantPage() {
</div> </div>
)} )}
</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>
</div> </div>
) )
+2 -1
View File
@@ -20,6 +20,7 @@ import {
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail, CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
} from "lucide-react" } from "lucide-react"
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions" import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
import { sanitizeSvg } from "@/lib/sanitize"
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogClose, DialogFooter, DialogClose,
@@ -1041,7 +1042,7 @@ const formatPreviewContent = (content: string) => {
{stickerData.emoji ? ( {stickerData.emoji ? (
<span className="text-7xl block text-center">{stickerData.emoji}</span> <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> </div>
) : voiceUrl ? ( ) : 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, status: 200,
headers: { headers: {
"Content-Type": "application/json", "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) { } catch (error) {
+26 -17
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query, transaction } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar" import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() { export async function GET() {
@@ -18,13 +18,18 @@ export async function GET() {
u.email AS other_user_email, u.email AS other_user_email,
u.phone AS other_user_phone, u.phone AS other_user_phone,
u.avatar_url AS other_user_avatar_url, 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, lm.last_message_data->>'content' 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->>'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 (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 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_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 conversation_participants cp ON cp.conversation_id = c.id
JOIN users u ON u.id = cp.user_id AND u.id != $1 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 ( WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1 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 }) return NextResponse.json({ conversationId: existing.rows[0].id })
} }
const convResult = await query( const result = await transaction(async (client) => {
`INSERT INTO conversations DEFAULT VALUES RETURNING id`, const convResult = await client.query(
) `INSERT INTO conversations DEFAULT VALUES RETURNING id`,
const conversationId = convResult.rows[0].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())`, `INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
[conversationId, user.id, userId], [conversationId, user.id, userId],
) )
const otherUser = await query( const otherResult = await client.query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url `SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users WHERE id = $1`, FROM users WHERE id = $1`,
[userId], [userId],
) )
const other = otherUser.rows[0] return { conversationId, other: otherResult.rows[0] }
})
const { conversationId, other } = result
return NextResponse.json({ return NextResponse.json({
conversation: { 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 } { function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 } if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
const pct = Math.round(((current - previous) / previous) * 100) const pct = Math.round(((current - previous) / previous) * 100)
return { pct: Math.abs(pct), up: pct >= 0 } 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) { export async function GET(request: NextRequest) {
try { try {
const user = await getSessionUser() const user = await getSessionUser()
@@ -138,19 +86,107 @@ export async function GET(request: NextRequest) {
prevRange = getPreviousPeriodRange(period, start) prevRange = getPreviousPeriodRange(period, start)
} }
const [currentLeads, prevLeads] = await Promise.all([ const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
fetchLeadsInRange(start, end, user.id, isAdmin),
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
])
const currentCounts = countStatuses(currentLeads) // Status counts for current period (SQL aggregation)
const prevCounts = countStatuses(prevLeads) 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 closedLeads = currentCounts.closed
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0 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, id: r.id,
companyName: r.company_name || "", companyName: r.company_name || "",
contactName: r.contact_name, contactName: r.contact_name,
@@ -158,7 +194,7 @@ export async function GET(request: NextRequest) {
phone: r.phone || "", phone: r.phone || "",
source: "", source: "",
description: r.notes || "", description: r.notes || "",
status: r.status, status: stageToStatus(r.stage_name),
assignedUserId: r.assigned_to, assignedUserId: r.assigned_to,
assignedUser: r.assigned_to ? { assignedUser: r.assigned_to ? {
id: r.user_id, id: r.user_id,
@@ -170,17 +206,14 @@ export async function GET(request: NextRequest) {
updatedAt: r.updated_at, updatedAt: r.updated_at,
})) }))
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined)
const trends = { const trends = {
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored, totalLeads: computeTrend(totalLeads, prevTotal),
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
openLeads: computeTrend(currentCounts.open, prevCounts.open), openLeads: computeTrend(currentCounts.open, prevCounts.open),
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted), contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending), pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed), closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
conversionRate: computeTrend(conversionRate, 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 = { const stats = {
@@ -192,9 +225,9 @@ export async function GET(request: NextRequest) {
ignoredLeads: currentCounts.ignored, ignoredLeads: currentCounts.ignored,
conversionRate, conversionRate,
monthlyBreakdown, 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, trends,
recentLeads: mappedLeads.slice(0, 10), recentLeads,
statusDistribution: [ statusDistribution: [
{ name: "Open", value: currentCounts.open, color: "#3b82f6" }, { name: "Open", value: currentCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" }, { name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
+79 -93
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query, transaction } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email" import { sendEventConfirmation } from "@/lib/email"
export async function GET(request: NextRequest) { 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 }) return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
} }
const result = await query( // Single transaction for all DB operations
`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) const result = await transaction(async (client) => {
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) // 1. Insert event
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`, 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)
user.id, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
participantId || null, 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`,
developerId || null, [
leadId || null, user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
conversationId || null, title, description || null, eventType || "website_creation", startTime || null,
title, eventType === "website_creation" ? null : (endTime || null),
description || null, eventType === "website_creation" ? null : (durationMinutes || null),
eventType || "website_creation", clientName || null, clientEmail || null, clientPhone || null,
startTime || null, ],
eventType === "website_creation" ? null : (endTime || null), )
eventType === "website_creation" ? null : (durationMinutes || null), const r = ins.rows[0]
clientName || null,
clientEmail || null,
clientPhone || null,
],
user.id,
)
const r = result.rows[0] // 2. Auto-update lead stage if website creation event
if (r.event_type === "website_creation" && leadId) {
const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
if (stageResult.rows.length > 0) {
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],
)
}
}
// Auto-update lead to "pending" when a website creation event is created // 3. Batch notifications (multi-row INSERT)
if (r.event_type === "website_creation" && leadId) { const notifications: { user_id: string; type: string; title: string; description: string; link: string; context_id: string; context_type: string }[] = []
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'") if (participantId && participantId !== user.id) {
if (stageResult.rows.length > 0) { notifications.push({
await query( user_id: participantId, type: "event_scheduled",
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`, title: `Meeting scheduled: ${title}`,
[stageResult.rows[0].id, leadId], description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
})
}
if (developerId && developerId !== user.id) {
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,
) )
} }
}
if (participantId && participantId !== user.id) { // 4. Fetch participant + developer in one query
await query( const idsToFetch = [participantId, developerId].filter(Boolean) as string[]
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) let usersMap: Record<string, { id: string; first_name: string; last_name: string; email: string }> = {}
VALUES ($1, $2, $3, $4, $5, $6, $7)`, if (idsToFetch.length > 0) {
[ const userPlaceholders = idsToFetch.map((_, i) => `$${i + 1}`).join(", ")
participantId, const userRows = await client.query(
"event_scheduled", `SELECT id, first_name, last_name, email FROM users WHERE id IN (${userPlaceholders})`,
`Meeting scheduled: ${title}`, idsToFetch,
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`, )
`/calendar`, for (const row of userRows.rows) {
r.id, usersMap[row.id] = row
"scheduled_event", }
], }
)
}
// Notify developer return { r, usersMap }
if (developerId && developerId !== user.id) { }, 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",
],
)
}
const participantResult = participantId ? await query( const { r, usersMap } = result
`SELECT email, first_name, last_name FROM users WHERE id = $1`, const participant = participantId ? usersMap[participantId] || null : null
[participantId], const dev = developerId ? usersMap[developerId] || null : null
) : null
const participant = participantResult?.rows[0] || null
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
// Email sent asynchronously outside transaction (side effect)
sendEventConfirmation({ sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`, creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email, creatorEmail: user.email,
@@ -215,28 +213,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
event: { event: {
id: r.id, id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_id,
userId: r.user_id, leadId: r.lead_id, conversationId: r.conversation_id,
participantId: r.participant_id, title: r.title, description: r.description, participantNotes: r.participant_notes,
developerId: r.developer_id, eventType: r.event_type, startTime: r.start_time, endTime: r.end_time,
leadId: r.lead_id, durationMinutes: r.duration_minutes, status: r.status, createdAt: r.created_at,
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,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role }, 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, developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null, lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
clientName: r.client_name || null, clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at,
}, },
}, { status: 201 }) }, { status: 201 })
} catch (error) { } catch (error) {
+27 -3
View File
@@ -1,15 +1,38 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser, setSessionContext } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import crypto from "crypto" import crypto from "crypto"
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { 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) { if (!phone) {
return NextResponse.json({ error: "Phone number required" }, { status: 400 }) 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( await query(
`INSERT INTO invites (token, phone) VALUES ($1, $2)`, `INSERT INTO invites (token, phone) VALUES ($1, $2)`,
[token, phone], [token, phone],
@@ -19,7 +42,8 @@ export async function POST(request: NextRequest) {
const inviteUrl = `${origin}/join/${token}` const inviteUrl = `${origin}/join/${token}`
return NextResponse.json({ token, inviteUrl }) return NextResponse.json({ token, inviteUrl })
} catch { } catch (error) {
console.error("Invite generation error:", error)
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 }) 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 offset = parseInt(searchParams.get("offset") || "0", 10)
const isAdmin = user.role === "admin" || user.role === "super_admin" 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[] = [] const params: any[] = []
let paramIdx = 1 let paramIdx = 1
const conditions: string[] = ["l.deleted_at IS NULL"]
if (period !== "all") { if (period !== "all") {
const range = getPeriodDateRange(period) const range = getPeriodDateRange(period)
if (range) { 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()) params.push(range.start.toISOString(), range.end.toISOString())
paramIdx += 2 paramIdx += 2
} }
} }
if (search) { 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}%`) params.push(`%${search}%`)
paramIdx++ paramIdx++
} }
if (!isAdmin) { if (!isAdmin) {
sql += ` AND l.assigned_to = $${paramIdx}` conditions.push(`l.assigned_to = $${paramIdx}`)
params.push(user.id) params.push(user.id)
paramIdx++ paramIdx++
} }
sql += ` ORDER BY l.created_at DESC` // Push status filter into SQL (avoid client-side filtering after pagination)
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}` const statusStageMap: Record<string, string[]> = {
params.push(limit, offset) open: ["New"],
paramIdx += 2 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) const s = stageToStatus(r.stage_name)
return { return {
id: r.id, id: r.id,
@@ -106,11 +133,7 @@ export async function GET(request: NextRequest) {
} }
}) })
if (status !== "all") { return NextResponse.json({ leads, total })
leads = leads.filter((l: any) => l.status === status)
}
return NextResponse.json(leads)
} catch (error) { } catch (error) {
console.error("Leads API error:", error) console.error("Leads API error:", error)
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 }) return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
+10
View File
@@ -1,7 +1,17 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
export async function GET(request: NextRequest) { 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") const phone = request.nextUrl.searchParams.get("phone")
if (!phone) { if (!phone) {
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 }) return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
+13 -5
View File
@@ -36,6 +36,7 @@ function formatContent(text: string) {
interface ChatMessage { interface ChatMessage {
role: "user" | "assistant" role: "user" | "assistant"
content: string content: string
ts?: number
gif?: { gif?: {
url: string url: string
previewUrl: string previewUrl: string
@@ -101,14 +102,20 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
} }
}, [showGifPicker]) }, [showGifPicker])
const _WEEK_MS = 604800000
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem("ai-chat-messages") const saved = localStorage.getItem("ai-chat-messages")
if (saved) { if (saved) {
try { try {
const parsed = JSON.parse(saved) let parsed = JSON.parse(saved)
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed) const cutoff = Date.now() - _WEEK_MS
loadedFromStorage.current = true parsed = parsed.filter((m: any) => !m.ts || m.ts > cutoff)
if (parsed.length > 0) {
setMessages(parsed)
loadedFromStorage.current = true
}
} }
} catch {} } catch {}
} else { } else {
@@ -118,7 +125,8 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
useEffect(() => { useEffect(() => {
if (messages.length > 0) { 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]) }, [messages])
@@ -140,6 +148,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}, []) }, [])
useEffect(() => { useEffect(() => {
checkServer()
if (loadedFromStorage.current) return if (loadedFromStorage.current) return
fetch("/api/ai/jobs") fetch("/api/ai/jobs")
.then((r) => r.json()) .then((r) => r.json())
@@ -169,7 +178,6 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}, },
]) ])
}) })
checkServer()
}, [checkServer]) }, [checkServer])
useEffect(() => { useEffect(() => {
+3 -3
View File
@@ -59,10 +59,10 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
key={i} key={i}
type="button" type="button"
onClick={() => handleSelect(job)} 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="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> </button>
))} ))}
{jobs.length === 0 && !loading && ( {jobs.length === 0 && !loading && (
@@ -76,7 +76,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
type="button" type="button"
onClick={() => onSearch(selected)} onClick={() => onSearch(selected)}
disabled={searching} 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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{searching ? "Searching..." : "Search Facebook"} {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 data from "@emoji-mart/data"
import Picker from "@emoji-mart/react" import Picker from "@emoji-mart/react"
import { getStickerPacks, type StickerPack } from "@/data/stickers" import { getStickerPacks, type StickerPack } from "@/data/stickers"
import { sanitizeSvg } from "@/lib/sanitize"
type Tab = "emoji" | "gif" | "sticker" 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" className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
> >
{sticker.svg ? ( {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> <span className="text-5xl">{sticker.emoji}</span>
)} )}
@@ -303,7 +304,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
if (!s) return null if (!s) return null
return ( 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"> <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> </button>
) )
})} })}
+4 -3
View File
@@ -61,7 +61,8 @@ export function ThemeSettings() {
<Label <Label
htmlFor={`mode-${value}`} htmlFor={`mode-${value}`}
className={cn( 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" "peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)} )}
> >
@@ -89,7 +90,7 @@ export function ThemeSettings() {
<Label <Label
htmlFor={`color-${value}`} htmlFor={`color-${value}`}
className={cn( 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" "peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)} )}
> >
@@ -117,7 +118,7 @@ export function ThemeSettings() {
<Label <Label
htmlFor={`bg-${value}`} htmlFor={`bg-${value}`}
className={cn( 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" "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 bcrypt from "bcryptjs";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { signToken, verifyToken } from "@/lib/jwt";
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);
export const SESSION_COOKIE = "session"; export const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5; const MAX_FAILED_ATTEMPTS = 5;
@@ -43,23 +29,6 @@ export async function comparePassword(
return bcrypt.compare(password, hash); 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) { export async function getUserByEmail(email: string) {
const result = await query( const result = await query(
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name, ` 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) { 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) { if (ip) {
await query("SELECT set_config('app.current_ip', $1, true)", [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 const dbUrl = process.env.DATABASE_URL
if (!dbUrl) { if (!dbUrl) {
@@ -9,6 +9,8 @@ const pool = new Pool({
max: 20, max: 20,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000, connectionTimeoutMillis: 5000,
statement_timeout: 30000,
idle_in_transaction_session_timeout: 10000,
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false }, 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 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 { NextResponse } from "next/server"
import type { NextRequest } from "next/server" import type { NextRequest } from "next/server"
import { verifyToken } from "@/lib/jwt"
const publicRoutes = [ const publicRoutes = [
"/login", "/login",
@@ -12,6 +13,8 @@ const publicRoutes = [
"/fonts", "/fonts",
] ]
const SESSION_COOKIE = "session"
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
@@ -23,9 +26,9 @@ export async function middleware(request: NextRequest) {
return NextResponse.next() 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/")) { if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
} }
@@ -34,7 +37,21 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl) 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 = { 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"),
},
},
})