Compare commits
23 Commits
b2bb6d94f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c9c855579b | |||
| 3a348e3616 | |||
| dba4c84cd5 | |||
| d77ff2b965 | |||
| 0bc3ca58ed | |||
| d793604e92 | |||
| bc83af8e00 | |||
| 80dee367e8 | |||
| da99b695be | |||
| dd6767980a | |||
| c1c4afadbb | |||
| 37679a7a60 | |||
| c0cb715ced | |||
| 1269f6cce8 | |||
| 370f935cbb | |||
| faf9dd551a | |||
| f67d9377a0 | |||
| 38fb3a47a8 | |||
| bc422edcf7 | |||
| da5f8360bc | |||
| 37af1febcc | |||
| 84632e043f | |||
| 566fa3df88 |
+35
-35
@@ -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
@@ -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
@@ -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,31 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Fixes: password_change_required + audit trigger UUID cast
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1. All users use the passwords already set in the seed — no forced change
|
||||||
|
UPDATE users SET password_change_required = FALSE WHERE password_change_required = TRUE;
|
||||||
|
|
||||||
|
-- 2. Fix audit_password_change trigger: current_setting returns TEXT but
|
||||||
|
-- audit_logs.changed_by is UUID — cast to UUID to prevent type error
|
||||||
|
CREATE OR REPLACE FUNCTION audit_password_change()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
table_name, record_id, action, old_data, new_data, changed_by, ip_address
|
||||||
|
) VALUES (
|
||||||
|
'users',
|
||||||
|
NEW.id,
|
||||||
|
'UPDATE',
|
||||||
|
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
|
||||||
|
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
|
||||||
|
NULLIF(current_setting('app.current_ip', true), '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- 3. Re-enable the trigger (was disabled as workaround for the UUID bug)
|
||||||
|
ALTER TABLE users ENABLE TRIGGER trg_audit_password_change;
|
||||||
@@ -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;
|
||||||
@@ -75,5 +75,12 @@ BEGIN;
|
|||||||
|
|
||||||
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
|
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
|
||||||
\i 019_allow_null_start_time.sql
|
\i 019_allow_null_start_time.sql
|
||||||
|
|
||||||
|
\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
|
||||||
|
\i 020_fixes.sql
|
||||||
|
|
||||||
|
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
|
||||||
|
\i 021_performance_indexes.sql
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+1535
-28
File diff suppressed because it is too large
Load Diff
+19
-15
@@ -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",
|
"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
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// ── Automated Database Migration Runner ──────────────────────────────
|
||||||
|
// Reads .env.local for DATABASE_URL, tracks applied migrations in
|
||||||
|
// a _migrations table, and runs unapplied .sql files via psql.
|
||||||
|
// Runs automatically on npm run dev and npm run setup.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from "node:fs"
|
||||||
|
import { execSync } from "node:child_process"
|
||||||
|
import { resolve, dirname } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = resolve(__dirname, "..")
|
||||||
|
|
||||||
|
// ── Load .env.local into process.env ─────────────────────────────────
|
||||||
|
|
||||||
|
const envPath = resolve(ROOT, ".env.local")
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue
|
||||||
|
const eq = trimmed.indexOf("=")
|
||||||
|
if (eq === -1) continue
|
||||||
|
const key = trimmed.slice(0, eq).trim()
|
||||||
|
let value = trimmed.slice(eq + 1).trim()
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
if (!process.env[key]) process.env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL
|
||||||
|
if (!DATABASE_URL) {
|
||||||
|
console.log(" ~ DATABASE_URL not set — skipping migrations")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find psql ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function findPsql() {
|
||||||
|
try {
|
||||||
|
execSync("psql --version", { stdio: "pipe", timeout: 5000 })
|
||||||
|
return "psql"
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
"C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe",
|
||||||
|
"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe",
|
||||||
|
"C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe",
|
||||||
|
"C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe",
|
||||||
|
]
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (existsSync(p)) return `"${p}"`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PSQL = findPsql()
|
||||||
|
if (!PSQL) {
|
||||||
|
console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Database connection (for tracking table) ─────────────────────────
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const { Pool } = require("pg")
|
||||||
|
|
||||||
|
let pool
|
||||||
|
try {
|
||||||
|
pool = new Pool({
|
||||||
|
connectionString: DATABASE_URL,
|
||||||
|
max: 1,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
idleTimeoutMillis: 10000,
|
||||||
|
})
|
||||||
|
await pool.query("SELECT 1")
|
||||||
|
} catch {
|
||||||
|
console.log(" ~ Database not available — skipping migrations")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Migration logic ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runMigrations() {
|
||||||
|
// 1. Create tracking table if not exists
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 2. Determine file order from run_all.sql (authoritative)
|
||||||
|
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
|
||||||
|
const runAllContent = readFileSync(runAllPath, "utf-8")
|
||||||
|
const fileOrder = []
|
||||||
|
for (const line of runAllContent.split("\n")) {
|
||||||
|
const match = line.match(/\\i\s+(\S+\.sql)/)
|
||||||
|
if (match) fileOrder.push(match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileOrder.length === 0) {
|
||||||
|
console.log(" ~ No migration files found in run_all.sql")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get already-applied files
|
||||||
|
let appliedSet
|
||||||
|
try {
|
||||||
|
const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename")
|
||||||
|
appliedSet = new Set(applied.map((r) => r.filename))
|
||||||
|
} catch {
|
||||||
|
appliedSet = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build psql command base
|
||||||
|
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
|
||||||
|
|
||||||
|
// 5. Run unapplied files in order.
|
||||||
|
// psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3.
|
||||||
|
// If the error is "already exists", the file was already applied before
|
||||||
|
// tracking existed — mark it as applied and continue.
|
||||||
|
// Other errors are genuine failures — abort.
|
||||||
|
const ALREADY_APPLIED_PATTERNS = [
|
||||||
|
"already exists",
|
||||||
|
"duplicate key",
|
||||||
|
"cannot insert multiple commands into a prepared statement",
|
||||||
|
]
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
for (const file of fileOrder) {
|
||||||
|
if (appliedSet.has(file)) continue
|
||||||
|
|
||||||
|
const filePath = resolve(ROOT, "database", "migrations", file)
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
console.error(` ✗ Migration file not found: ${file}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let stderr = ""
|
||||||
|
try {
|
||||||
|
const result = execSync(`${psqlCmd} -f "${filePath}"`, {
|
||||||
|
timeout: 30000,
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
// psql outputs notices/warnings to stderr even on success
|
||||||
|
stderr = result.stderr || ""
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.stderr || err.stdout || err.message
|
||||||
|
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
|
||||||
|
if (isAlreadyApplied) {
|
||||||
|
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
|
||||||
|
console.log(` ~ Already applied: ${file}`)
|
||||||
|
count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
console.error(` ✗ Migration failed: ${file}`)
|
||||||
|
console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
|
||||||
|
console.log(` ✓ Applied: ${file}`)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
console.log(" ~ All migrations up to date")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runMigrations()
|
||||||
|
} finally {
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
+3
-54
@@ -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,60 +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. Self-healing build check (--self-heal flag)
|
// 5. Final summary
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 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! ===")
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
setSessionContext,
|
setSessionContext,
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
|
||||||
|
|
||||||
function jsonResponse(data: unknown, status: number) {
|
function jsonResponse(data: unknown, status: number) {
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
@@ -115,11 +115,6 @@ export async function POST(request: NextRequest) {
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
await query(
|
|
||||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
|
||||||
[dbUser.id, JSON.stringify({ website_theme: "default" })],
|
|
||||||
)
|
|
||||||
|
|
||||||
const token = await createSession(dbUser.id, dbUser.role_name)
|
const token = await createSession(dbUser.id, dbUser.role_name)
|
||||||
await setSessionContext(dbUser.id, ipAddress)
|
await setSessionContext(dbUser.id, ipAddress)
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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 })
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ export async function GET() {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`,
|
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
|
||||||
[user.id],
|
[user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const websiteTheme = result.rows[0]?.website_theme || "default"
|
const websiteTheme = result.rows[0]?.website_theme || "default"
|
||||||
return NextResponse.json({ websiteTheme })
|
const colorTheme = result.rows[0]?.color_theme || null
|
||||||
|
return NextResponse.json({ websiteTheme, colorTheme })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Website theme GET error:", error)
|
console.error("Website theme GET error:", error)
|
||||||
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
|
||||||
@@ -26,14 +27,31 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const theme = body.websiteTheme || "default"
|
const update: Record<string, string> = {}
|
||||||
|
if (body.websiteTheme !== undefined) {
|
||||||
|
update.website_theme = body.websiteTheme || "default"
|
||||||
|
}
|
||||||
|
if (body.colorTheme !== undefined) {
|
||||||
|
update.color_theme = body.colorTheme || "default"
|
||||||
|
}
|
||||||
|
|
||||||
await query(
|
if (Object.keys(update).length > 0) {
|
||||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
await query(
|
||||||
[user.id, JSON.stringify({ website_theme: theme })],
|
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||||
|
[user.id, JSON.stringify(update)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
|
||||||
|
[user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
return NextResponse.json({ success: true, websiteTheme: theme })
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
websiteTheme: result.rows[0]?.website_theme || "default",
|
||||||
|
colorTheme: result.rows[0]?.color_theme || null,
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Website theme PUT error:", error)
|
console.error("Website theme PUT error:", error)
|
||||||
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} min-h-screen antialiased`}>
|
<body className={`${inter.variable} min-h-screen antialiased`}>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var t=localStorage.getItem("crm-color-theme");if(t&&t!=="default")document.documentElement.classList.add(t);var w=localStorage.getItem("crm-website-theme");if(w==="spidey")document.documentElement.classList.add("theme-spidey")}catch(e){}})()`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
|
||||||
<WebsiteThemeProvider>
|
<WebsiteThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -41,32 +41,32 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className="w-full flex items-center gap-2.5 bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/30 rounded-xl px-4 py-3 text-sm text-[#9ca3af] hover:text-[#e5e7eb] transition-all duration-200"
|
className="w-full flex items-center gap-2.5 bg-card/50 border border-border hover:border-primary/20 rounded-xl px-4 py-3 text-sm text-muted-foreground hover:text-foreground transition-all duration-200"
|
||||||
>
|
>
|
||||||
<Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
|
<Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
|
||||||
<span className="flex-1 text-left truncate">
|
<span className="flex-1 text-left truncate">
|
||||||
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
|
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
|
||||||
</span>
|
</span>
|
||||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-[#f97316]" /> : <ChevronDown className={`h-3.5 w-3.5 text-[#4b5563] transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
|
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> : <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||||
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-[#1a1d2e] border border-[#ffffff0f] rounded-xl shadow-xl shadow-black/40 max-h-60 overflow-y-auto">
|
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-card border border-border rounded-xl shadow-xl max-h-60 overflow-y-auto">
|
||||||
{jobs.map((job, i) => (
|
{jobs.map((job, i) => (
|
||||||
<button
|
<button
|
||||||
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-[#9ca3af] hover:bg-[#1f2437] hover:text-[#e5e7eb] transition-all duration-150 border-b border-[#ffffff08] last:border-0 border-l-2 border-l-transparent hover:border-l-[#f97316]/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-[#4b5563] mt-0.5">{job.industry} — {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 && (
|
||||||
<div className="px-4 py-4 text-xs text-[#4b5563] text-center">No job categories loaded</div>
|
<div className="px-4 py-4 text-xs text-muted-foreground/60 text-center">No job categories loaded</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { cn } from "@/lib/utils"
|
|||||||
import { Sun, Moon, Monitor, Palette, Shield } from "lucide-react"
|
import { Sun, Moon, Monitor, Palette, Shield } from "lucide-react"
|
||||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
|
||||||
const COLOR_THEME_KEY = "crm-color-theme"
|
|
||||||
|
|
||||||
const modeOptions = [
|
const modeOptions = [
|
||||||
{ value: "light", icon: Sun, label: "Light" },
|
{ value: "light", icon: Sun, label: "Light" },
|
||||||
{ value: "dark", icon: Moon, label: "Dark" },
|
{ value: "dark", icon: Moon, label: "Dark" },
|
||||||
@@ -35,38 +33,15 @@ const backgroundOptions = [
|
|||||||
{ value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" },
|
{ value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" },
|
||||||
]
|
]
|
||||||
|
|
||||||
function getStoredColorTheme(): string {
|
|
||||||
if (typeof window === "undefined") return "default"
|
|
||||||
return localStorage.getItem(COLOR_THEME_KEY) || "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyColorTheme(theme: string) {
|
|
||||||
const colorThemes = ["default","ocean","forest","sunset","midnight","rose","amber","violet","slate","ruby"]
|
|
||||||
const classes = document.documentElement.className.split(" ").filter(c => !colorThemes.includes(c))
|
|
||||||
if (theme !== "default") {
|
|
||||||
classes.push(theme)
|
|
||||||
}
|
|
||||||
document.documentElement.className = classes.join(" ").trim()
|
|
||||||
localStorage.setItem(COLOR_THEME_KEY, theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeSettings() {
|
export function ThemeSettings() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
|
const { websiteTheme, setWebsiteTheme, colorTheme, setColorTheme } = useWebsiteTheme()
|
||||||
const [colorTheme, setColorTheme] = useState("default")
|
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
setColorTheme(getStoredColorTheme())
|
|
||||||
applyColorTheme(getStoredColorTheme())
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function handleColorChange(value: string) {
|
|
||||||
setColorTheme(value)
|
|
||||||
applyColorTheme(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -107,14 +83,14 @@ export function ThemeSettings() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RadioGroup value={colorTheme} onValueChange={handleColorChange} className="grid grid-cols-5 gap-4">
|
<RadioGroup value={colorTheme} onValueChange={setColorTheme} className="grid grid-cols-5 gap-4">
|
||||||
{themeOptions.map(({ value, label, color, ring }) => (
|
{themeOptions.map(({ value, label, color, ring }) => (
|
||||||
<div key={value}>
|
<div key={value}>
|
||||||
<RadioGroupItem value={value} id={`color-${value}`} className="peer sr-only" />
|
<RadioGroupItem value={value} id={`color-${value}`} className="peer sr-only" />
|
||||||
<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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -142,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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -3,14 +3,19 @@
|
|||||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
|
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
|
||||||
|
|
||||||
const WEBSITE_THEME_KEY = "crm-website-theme"
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||||
|
const COLOR_THEME_KEY = "crm-color-theme"
|
||||||
|
|
||||||
const themeClasses: Record<string, string> = {
|
const themeClasses: Record<string, string> = {
|
||||||
spidey: "theme-spidey",
|
spidey: "theme-spidey",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const colorThemes = ["default","ocean","forest","sunset","midnight","rose","amber","violet","slate","ruby"]
|
||||||
|
|
||||||
interface WebsiteThemeContextValue {
|
interface WebsiteThemeContextValue {
|
||||||
websiteTheme: string
|
websiteTheme: string
|
||||||
setWebsiteTheme: (theme: string) => Promise<void>
|
setWebsiteTheme: (theme: string) => Promise<void>
|
||||||
|
colorTheme: string
|
||||||
|
setColorTheme: (theme: string) => Promise<void>
|
||||||
loading: boolean
|
loading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,9 +32,19 @@ function applyWebsiteTheme(theme: string) {
|
|||||||
root.className = classes.join(" ").trim()
|
root.className = classes.join(" ").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStoredTheme(): string | null {
|
function applyColorTheme(theme: string) {
|
||||||
|
const root = document.documentElement
|
||||||
|
const classes = root.className.split(" ").filter((c) => !colorThemes.includes(c))
|
||||||
|
if (theme !== "default") {
|
||||||
|
classes.push(theme)
|
||||||
|
}
|
||||||
|
root.className = classes.join(" ").trim()
|
||||||
|
localStorage.setItem(COLOR_THEME_KEY, theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStored(key: string): string | null {
|
||||||
if (typeof window === "undefined") return null
|
if (typeof window === "undefined") return null
|
||||||
return localStorage.getItem(WEBSITE_THEME_KEY)
|
return localStorage.getItem(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeTheme(theme: string) {
|
function storeTheme(theme: string) {
|
||||||
@@ -39,22 +54,40 @@ function storeTheme(theme: string) {
|
|||||||
|
|
||||||
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [websiteTheme, setWebsiteThemeState] = useState<string>("default")
|
const [websiteTheme, setWebsiteThemeState] = useState<string>("default")
|
||||||
|
const [colorTheme, setColorThemeState] = useState<string>("default")
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/settings/website-theme")
|
fetch("/api/settings/website-theme")
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const theme = data?.websiteTheme || "default"
|
const wt = data?.websiteTheme || "default"
|
||||||
setWebsiteThemeState(theme)
|
setWebsiteThemeState(wt)
|
||||||
storeTheme(theme)
|
storeTheme(wt)
|
||||||
applyWebsiteTheme(theme)
|
applyWebsiteTheme(wt)
|
||||||
|
|
||||||
|
if (data?.colorTheme) {
|
||||||
|
setColorThemeState(data.colorTheme)
|
||||||
|
applyColorTheme(data.colorTheme)
|
||||||
|
} else {
|
||||||
|
const stored = getStored(COLOR_THEME_KEY)
|
||||||
|
if (stored) {
|
||||||
|
setColorThemeState(stored)
|
||||||
|
applyColorTheme(stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
const stored = getStoredTheme()
|
const stored = getStored(WEBSITE_THEME_KEY)
|
||||||
const theme = stored || "default"
|
const theme = stored || "default"
|
||||||
setWebsiteThemeState(theme)
|
setWebsiteThemeState(theme)
|
||||||
applyWebsiteTheme(theme)
|
applyWebsiteTheme(theme)
|
||||||
|
|
||||||
|
const storedColor = getStored(COLOR_THEME_KEY)
|
||||||
|
if (storedColor) {
|
||||||
|
setColorThemeState(storedColor)
|
||||||
|
applyColorTheme(storedColor)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -74,8 +107,22 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setColorTheme = useCallback(async (theme: string) => {
|
||||||
|
setColorThemeState(theme)
|
||||||
|
applyColorTheme(theme)
|
||||||
|
try {
|
||||||
|
await fetch("/api/settings/website-theme", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ colorTheme: theme }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.warn("Failed to persist color theme")
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebsiteThemeContext.Provider value={{ websiteTheme, setWebsiteTheme, loading }}>
|
<WebsiteThemeContext.Provider value={{ websiteTheme, setWebsiteTheme, colorTheme, setColorTheme, loading }}>
|
||||||
{children}
|
{children}
|
||||||
</WebsiteThemeContext.Provider>
|
</WebsiteThemeContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user