Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec8207fca7 | |||
| 33a4f9dfa9 | |||
| 82e8ce8ae9 | |||
| 343f814569 | |||
| 4c09635d30 | |||
| 20a1744e7f | |||
| c9df7787d5 | |||
| fa39f2af9d | |||
| 9bbaf70145 | |||
| 5668a63370 | |||
| 3af622f53d | |||
| 1465016b56 | |||
| 906e37e845 | |||
| 52a489759f | |||
| 1f032dc098 | |||
| 1adb6544c2 | |||
| 56ff9995a2 | |||
| 33753a1291 | |||
| 06912f3e59 | |||
| f7258d38f6 | |||
| 4c1d994054 | |||
| b245b352e7 | |||
| cd797c5b3c | |||
| 9d9309833b | |||
| 1e332fb50d | |||
| 9acbbb6a19 | |||
| 8a6ad5c6c6 |
+85
-1
@@ -29,7 +29,7 @@ try {
|
|||||||
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
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 || "sam860/dolphin3-llama3.2:3b"
|
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
|
||||||
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")
|
||||||
@@ -96,7 +96,55 @@ function appendToImprovementLog(entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat handler ────────────────────────────────────────────────
|
// ── Chat handler ────────────────────────────────────────────────
|
||||||
|
async function scrapeFacebook() {
|
||||||
|
const profilePath = process.env.FX_PROFILE || ""
|
||||||
|
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
||||||
|
const logPath = "C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-scrape-debug.log"
|
||||||
|
try {
|
||||||
|
const body = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ hostname: "127.0.0.1", port: 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => {
|
||||||
|
let data = ""
|
||||||
|
res.on("data", (c) => data += c)
|
||||||
|
res.on("end", () => resolve(data))
|
||||||
|
res.on("error", reject)
|
||||||
|
})
|
||||||
|
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
|
||||||
|
req.on("error", reject)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
const data = JSON.parse(body)
|
||||||
|
return data
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLeads(leads) {
|
||||||
|
if (!leads || leads.length === 0) return "No leads found from the latest scrape."
|
||||||
|
let output = `**${leads.length} leads found:**\n\n`
|
||||||
|
for (let i = 0; i < leads.length; i++) {
|
||||||
|
const l = leads[i]
|
||||||
|
output += `${i + 1}. ${l.title || "No title"}\n`
|
||||||
|
if (l.author) output += ` Author: ${l.author}\n`
|
||||||
|
if (l.date) output += ` Date: ${l.date}\n`
|
||||||
|
if (l.url) output += ` URL: ${l.url}\n`
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
return output.trim()
|
||||||
|
}
|
||||||
|
|
||||||
async function handleChat(userMessage, userId, userRole) {
|
async function handleChat(userMessage, userId, userRole) {
|
||||||
|
const lowerMsg = userMessage.toLowerCase()
|
||||||
|
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
|
||||||
|
|
||||||
|
if (triggerWords.some(w => lowerMsg.includes(w))) {
|
||||||
|
const result = await scrapeFacebook()
|
||||||
|
if (result && result.success) {
|
||||||
|
return formatLeads(result.leads)
|
||||||
|
}
|
||||||
|
return "Scraper returned no results or encountered an error. Try again later."
|
||||||
|
}
|
||||||
|
|
||||||
const jobs = loadedJobs
|
const jobs = loadedJobs
|
||||||
const instructions = readInstructions()
|
const instructions = readInstructions()
|
||||||
|
|
||||||
@@ -208,11 +256,47 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const { pathname } = parseURL(req)
|
const { pathname } = parseURL(req)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// GET /splash — loading screen
|
||||||
|
if (req.method === "GET" && pathname === "/splash") {
|
||||||
|
const splashPath = path.join(ROOT, "splash.html")
|
||||||
|
if (fs.existsSync(splashPath)) {
|
||||||
|
const html = fs.readFileSync(splashPath, "utf-8")
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
|
res.end(html)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(res, 200, { status: "ok" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// GET /health
|
// GET /health
|
||||||
if (req.method === "GET" && pathname === "/health") {
|
if (req.method === "GET" && pathname === "/health") {
|
||||||
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /status — combined health of all services
|
||||||
|
if (req.method === "GET" && pathname === "/status") {
|
||||||
|
const { default: http } = await import("http")
|
||||||
|
const results = { ai: true }
|
||||||
|
// Check scraper
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||||
|
r.on("error", reject)
|
||||||
|
})
|
||||||
|
results.scraper = true
|
||||||
|
} catch { results.scraper = false }
|
||||||
|
// Check frontend
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||||
|
r.on("error", reject)
|
||||||
|
})
|
||||||
|
results.frontend = true
|
||||||
|
} catch { results.frontend = false }
|
||||||
|
return sendJSON(res, 200, results)
|
||||||
|
}
|
||||||
|
|
||||||
// GET /ai/jobs
|
// GET /ai/jobs
|
||||||
if (req.method === "GET" && pathname === "/ai/jobs") {
|
if (req.method === "GET" && pathname === "/ai/jobs") {
|
||||||
return sendJSON(res, 200, { jobs: loadedJobs })
|
return sendJSON(res, 200, { jobs: loadedJobs })
|
||||||
|
|||||||
+717
-89
@@ -1,9 +1,21 @@
|
|||||||
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging
|
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging, tempfile
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Query, Body
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
from browser_use import Agent, Browser
|
||||||
|
from langchain_ollama import ChatOllama
|
||||||
|
|
||||||
|
|
||||||
|
def make_ollama(model: str | None = None, **kwargs) -> ChatOllama:
|
||||||
|
"""Create ChatOllama with required attrs for browser-use Agent compatibility."""
|
||||||
|
llm = ChatOllama(model=model or CLASSIFY_MODEL, **kwargs)
|
||||||
|
object.__setattr__(llm, 'provider', 'ollama')
|
||||||
|
object.__setattr__(llm, 'name', 'ChatOllama')
|
||||||
|
object.__setattr__(llm, 'model_name', llm.model)
|
||||||
|
return llm
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -20,7 +32,146 @@ PORT = int(os.getenv("PORT", "3008"))
|
|||||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
||||||
|
|
||||||
|
|
||||||
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
||||||
|
CHROME_PROFILE = os.getenv('CHROME_PROFILE', '')
|
||||||
|
|
||||||
|
CHROME_LAUNCH_ARGS = [
|
||||||
|
'--headless=new',
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
'--disable-background-networking',
|
||||||
|
'--no-first-run',
|
||||||
|
'--disable-sync',
|
||||||
|
'--disable-field-trial-config',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-backgrounding-occluded-windows',
|
||||||
|
'--disable-breakpad',
|
||||||
|
'--disable-component-update',
|
||||||
|
'--disable-default-apps',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-popup-blocking',
|
||||||
|
'--disable-prompt-on-repost',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
'--metrics-recording-only',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-extensions-http-throttling',
|
||||||
|
'--disable-hang-monitor',
|
||||||
|
'--disable-ipc-flooding-protection',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_browser_from_profile(profile_path: str) -> str | None:
|
||||||
|
"""Detect browser type from profile path. Returns 'firefox', 'chromium', or None."""
|
||||||
|
if not profile_path:
|
||||||
|
return None
|
||||||
|
p = profile_path.lower().replace('\\', '/')
|
||||||
|
if 'firefox' in p or '.default' in p or '.dev-edition' in p:
|
||||||
|
return 'firefox'
|
||||||
|
if 'chrome' in p or 'chromium' in p or 'edge' in p:
|
||||||
|
return 'chromium'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def copy_firefox_profile(src_path: str) -> str:
|
||||||
|
"""Copy essential Firefox profile files to a temp dir for Playwright."""
|
||||||
|
essential = ['cookies.sqlite', 'webappsstore.sqlite', 'permissions.sqlite']
|
||||||
|
dst = tempfile.mkdtemp(prefix='fb_fx_profile_')
|
||||||
|
for f_name in essential:
|
||||||
|
s = os.path.join(src_path, f_name)
|
||||||
|
if os.path.exists(s):
|
||||||
|
shutil.copy2(s, os.path.join(dst, f_name))
|
||||||
|
with open(os.path.join(dst, 'profiles.ini'), 'w') as fh:
|
||||||
|
fh.write("[Profile0]\nName=default\nIsRelative=0\nPath=.\nDefault=yes\n")
|
||||||
|
logger.info("Copied Firefox profile to %s", dst)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def copy_chrome_profile(user_data_dir: str, profile_dir: str = 'Default') -> str:
|
||||||
|
"""Copy Chrome profile to temp dir for launch_persistent_context."""
|
||||||
|
essential = ['Cookies', 'Login Data', 'Bookmarks', 'Web Data']
|
||||||
|
dst = tempfile.mkdtemp(prefix='fb_chrome_udir_')
|
||||||
|
src_profile = os.path.join(user_data_dir, profile_dir)
|
||||||
|
os.makedirs(dst, exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(dst, profile_dir), exist_ok=True)
|
||||||
|
dst_profile = os.path.join(dst, profile_dir)
|
||||||
|
for f_name in essential:
|
||||||
|
s = os.path.join(src_profile, f_name)
|
||||||
|
if os.path.exists(s):
|
||||||
|
shutil.copy2(s, os.path.join(dst_profile, f_name))
|
||||||
|
for sub in ['Local Storage', 'Session Storage']:
|
||||||
|
s = os.path.join(src_profile, sub)
|
||||||
|
if os.path.isdir(s):
|
||||||
|
shutil.copytree(s, os.path.join(dst_profile, sub), dirs_exist_ok=True)
|
||||||
|
local_state_src = os.path.join(user_data_dir, 'Local State')
|
||||||
|
local_state_dst = os.path.join(dst, 'Local State')
|
||||||
|
if os.path.exists(local_state_src):
|
||||||
|
shutil.copy2(local_state_src, local_state_dst)
|
||||||
|
logger.info("Copied Chrome profile from %s to %s", src_profile, dst)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ublock_extension() -> str | None:
|
||||||
|
"""Download and extract uBlock Origin extension for ad blocking."""
|
||||||
|
ext_dir = os.path.join(tempfile.gettempdir(), 'fb_ublock_origin')
|
||||||
|
manifest = os.path.join(ext_dir, 'manifest.json')
|
||||||
|
if os.path.exists(manifest):
|
||||||
|
return ext_dir
|
||||||
|
import urllib.request, zipfile
|
||||||
|
crx_url = ('https://clients2.google.com/service/update2/crx'
|
||||||
|
'?response=redirect&prodversion=133&acceptformat=crx3'
|
||||||
|
'&x=id%3Dddkjiahejlhfcafbddmgiahcphecmpfh%26uc')
|
||||||
|
crx_path = os.path.join(tempfile.gettempdir(), 'ublock_origin.crx')
|
||||||
|
try:
|
||||||
|
if not os.path.exists(crx_path):
|
||||||
|
logger.info("Downloading uBlock Origin extension...")
|
||||||
|
urllib.request.urlretrieve(crx_url, crx_path)
|
||||||
|
with zipfile.ZipFile(crx_path, 'r') as z:
|
||||||
|
z.extractall(ext_dir)
|
||||||
|
logger.info("uBlock Origin extension ready at %s", ext_dir)
|
||||||
|
return ext_dir
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to load uBlock Origin extension: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_agent_posts(agent_result: str, page_text: str) -> list[dict]:
|
||||||
|
"""Parse posts from agent output or page text fallback."""
|
||||||
|
posts = []
|
||||||
|
# Try JSON from agent output
|
||||||
|
json_match = re.search(r'\[.*?\]', agent_result, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(json_match.group())
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
for item in parsed:
|
||||||
|
if isinstance(item, dict) and item.get('content'):
|
||||||
|
posts.append({
|
||||||
|
'content': item.get('content', ''),
|
||||||
|
'author': item.get('author', '') or item.get('publisher', '') or '',
|
||||||
|
'url': item.get('url', ''),
|
||||||
|
'date': item.get('date', ''),
|
||||||
|
})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not posts:
|
||||||
|
lines = [l.strip() for l in agent_result.split('\n') if l.strip()]
|
||||||
|
cur = []
|
||||||
|
for l in lines:
|
||||||
|
if l.startswith(('1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.', '- ', '* ')):
|
||||||
|
if cur:
|
||||||
|
combined = ' '.join(cur)
|
||||||
|
if len(combined) > 30:
|
||||||
|
posts.append({'content': combined[:500], 'author': '', 'url': '', 'date': ''})
|
||||||
|
cur = []
|
||||||
|
cur.append(l)
|
||||||
|
if cur:
|
||||||
|
combined = ' '.join(cur)
|
||||||
|
if len(combined) > 30:
|
||||||
|
posts.append({'content': combined[:500], 'author': '', 'url': '', 'date': ''})
|
||||||
|
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
BROAD_KEYWORDS = [
|
BROAD_KEYWORDS = [
|
||||||
"website", "web design", "web develop", "web dev",
|
"website", "web design", "web develop", "web dev",
|
||||||
@@ -37,10 +188,166 @@ BROAD_KEYWORDS = [
|
|||||||
"want someone", "need help with",
|
"want someone", "need help with",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
OFFER_PATTERNS = [
|
||||||
|
r"\bfree\s+domain\b",
|
||||||
|
r"\bweb\s+hosting\b",
|
||||||
|
r"\bfree\s+website\b",
|
||||||
|
r"\bprofessional\s+website",
|
||||||
|
r"\baffordable\s+web\b",
|
||||||
|
r"\bget\s+a\s+free\b",
|
||||||
|
r"\bsee\s+more\b",
|
||||||
|
r"\bbook\s+your\b",
|
||||||
|
r"\bhire\s+a\s+freelancer\b",
|
||||||
|
r"\bcontent\s+strategy\b",
|
||||||
|
r"\bdigital\s+marketing\b",
|
||||||
|
r"\bseo\s+service",
|
||||||
|
r"\bsocial\s+media\b",
|
||||||
|
r"\blimited\s+time\s+offer\b",
|
||||||
|
r"\bspecial\s+offer\b",
|
||||||
|
r"\bsign\s+up\s+now\b",
|
||||||
|
r"\br\d[\d,.]",
|
||||||
|
r"\bstarting\s+at\b",
|
||||||
|
r"\bper\s+month\b",
|
||||||
|
r"\bmonthly\b",
|
||||||
|
r"\bfreelancer\s+marketplace\b",
|
||||||
|
r"\bfirst\s+order\b",
|
||||||
|
r"\bdomain\s+name\b",
|
||||||
|
r"\bregister\s+your\s+domain\b",
|
||||||
|
r"\bsponsored\b",
|
||||||
|
r"\bpromoted\b",
|
||||||
|
r"\badvertisement\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
REQUEST_PATTERNS = [
|
||||||
|
r"\bi\s+need\b",
|
||||||
|
r"\bi\s+need\s+someone\b",
|
||||||
|
r"\bi\s+need\s+a\b",
|
||||||
|
r"\bi\s+need\s+an\b",
|
||||||
|
r"\bi\s+want\b",
|
||||||
|
r"\bi\s+would\s+like\b",
|
||||||
|
r"\bi'm\s+looking\b",
|
||||||
|
r"\bi\s+am\s+looking\b",
|
||||||
|
r"\blooking\s+for\b",
|
||||||
|
r"\blooking\s+to\b",
|
||||||
|
r"\bwho\s+can\b",
|
||||||
|
r"\bcan\s+someone\b",
|
||||||
|
r"\bhelp\s+me\b",
|
||||||
|
r"\bneed\s+help\b",
|
||||||
|
r"\bwould\s+like\s+to\b",
|
||||||
|
r"\bwant\s+to\b",
|
||||||
|
r"\banyone\s+know\b",
|
||||||
|
r"\bdoes\s+anyone\b",
|
||||||
|
r"\brecommend\b",
|
||||||
|
r"\bsuggestion\b",
|
||||||
|
r"\bquote\s+(please|for|to)\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
def firefox_init_script() -> str:
|
||||||
|
return r"""// Firefox Anti-Detection
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||||
|
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||||
|
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
||||||
|
Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 0 });
|
||||||
|
Object.defineProperty(navigator, 'oscpu', { get: () => 'Windows NT 10.0; Win64; x64' });
|
||||||
|
Object.defineProperty(navigator, 'vendor', { get: () => '' });
|
||||||
|
Object.defineProperty(navigator, 'vendorSub', { get: () => '' });
|
||||||
|
Object.defineProperty(navigator, 'productSub', { get: () => '20100101' });
|
||||||
|
Object.defineProperty(navigator, 'product', { get: () => 'Gecko' });
|
||||||
|
Object.defineProperty(navigator, 'appCodeName', { get: () => 'Mozilla' });
|
||||||
|
Object.defineProperty(navigator, 'appName', { get: () => 'Netscape' });
|
||||||
|
Object.defineProperty(navigator, 'appVersion', { get: () => '5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0' });
|
||||||
|
Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0' });
|
||||||
|
Object.defineProperty(navigator, 'buildID', { get: () => '20250101000000' });
|
||||||
|
if (window.chrome !== undefined) { window.chrome = undefined; }
|
||||||
|
if (navigator.permissions) {
|
||||||
|
const origQ = navigator.permissions.query.bind(navigator.permissions);
|
||||||
|
navigator.permissions.query = (p) => origQ(p).then(r => (['camera','microphone','geolocation','notifications'].includes(p.name) ? Object.assign(r, { state: 'prompt' }) : r));
|
||||||
|
}
|
||||||
|
const _gEC = HTMLCanvasElement.prototype.getContext;
|
||||||
|
HTMLCanvasElement.prototype.getContext = function(t, ...a) {
|
||||||
|
const ctx = _gEC.call(this, t, ...a);
|
||||||
|
if (ctx && (t === 'webgl' || t === 'webgl2')) {
|
||||||
|
const _gP = ctx.getParameter.bind(ctx);
|
||||||
|
ctx.getParameter = function(p) { if (p===37445) return 'Intel Inc.'; if (p===37446) return 'Intel Iris OpenGL Engine'; if (p===7936||p===7937||p===35724) return ''; return _gP(p); };
|
||||||
|
const _gE = ctx.getExtension.bind(ctx);
|
||||||
|
ctx.getExtension = function(n) { if (n==='WEBGL_debug_renderer_info') return null; return _gE(n); };
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
const _tDU = HTMLCanvasElement.prototype.toDataURL;
|
||||||
|
HTMLCanvasElement.prototype.toDataURL = function(...a) {
|
||||||
|
const img = _tDU.apply(this, a);
|
||||||
|
if (img.includes('image/png') && img.length > 5000) { const n = btoa(String.fromCharCode(Math.floor(Math.random()*256))); return img.slice(0,-100)+n+img.slice(-100); }
|
||||||
|
return img;
|
||||||
|
};
|
||||||
|
const _gFD = AnalyserNode.prototype.getFloatFrequencyData;
|
||||||
|
if (_gFD) { AnalyserNode.prototype.getFloatFrequencyData = function(a) { _gFD.call(this,a); for(let i=0;i<a.length;i++) a[i]+=(Math.random()-0.5)*0.02; }; }
|
||||||
|
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
|
||||||
|
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
|
||||||
|
Object.defineProperty(navigator, 'plugins', { get: () => [{name:'Widevine Content Decryption Module',filename:'widevinecdm.dll',length:1,description:'Enables Widevine licenses'},{name:'OpenH264 Video Codec',filename:'openh264.dll',length:1,description:'H.264 video codec'}] });
|
||||||
|
Object.defineProperty(navigator, 'mimeTypes', { get: () => [{type:'application/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]}] });"""
|
||||||
|
|
||||||
|
|
||||||
|
def chromium_init_script() -> str:
|
||||||
|
return r"""// Enhanced Chromium Anti-Detection
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||||
|
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||||
|
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||||
|
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
||||||
|
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||||
|
Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 0 });
|
||||||
|
Object.defineProperty(navigator, 'oscpu', { get: () => 'Windows NT 10.0; Win64; x64' });
|
||||||
|
Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' });
|
||||||
|
Object.defineProperty(navigator, 'vendorSub', { get: () => '' });
|
||||||
|
Object.defineProperty(navigator, 'productSub', { get: () => '20030107' });
|
||||||
|
Object.defineProperty(navigator, 'product', { get: () => 'Gecko' });
|
||||||
|
Object.defineProperty(navigator, 'appCodeName', { get: () => 'Mozilla' });
|
||||||
|
Object.defineProperty(navigator, 'appName', { get: () => 'Netscape' });
|
||||||
|
Object.defineProperty(navigator, 'appVersion', { get: () => '5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' });
|
||||||
|
Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' });
|
||||||
|
window.chrome = { runtime: { onConnect: { addListener: () => {} }, onMessage: { addListener: () => {} }, sendMessage: () => {} }, app: { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, getDetails: () => null, getIsInstalled: () => false }, csi: () => {}, loadTimes: () => {} };
|
||||||
|
if (navigator.permissions) {
|
||||||
|
const origQ = navigator.permissions.query.bind(navigator.permissions);
|
||||||
|
navigator.permissions.query = (p) => origQ(p).then(r => (['camera','microphone','geolocation','notifications'].includes(p.name) ? Object.assign(r, { state: 'prompt' }) : r));
|
||||||
|
}
|
||||||
|
const _gEC = HTMLCanvasElement.prototype.getContext;
|
||||||
|
HTMLCanvasElement.prototype.getContext = function(t, ...a) {
|
||||||
|
const ctx = _gEC.call(this, t, ...a);
|
||||||
|
if (ctx && (t === 'webgl' || t === 'webgl2')) {
|
||||||
|
const _gP = ctx.getParameter.bind(ctx);
|
||||||
|
ctx.getParameter = function(p) { if (p===37445) return 'Intel Inc.'; if (p===37446) return 'Intel Iris OpenGL Engine'; if (p===7936||p===7937||p===35724) return ''; return _gP(p); };
|
||||||
|
const _gE = ctx.getExtension.bind(ctx);
|
||||||
|
ctx.getExtension = function(n) { if (n==='WEBGL_debug_renderer_info') return null; return _gE(n); };
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
const _tDU = HTMLCanvasElement.prototype.toDataURL;
|
||||||
|
HTMLCanvasElement.prototype.toDataURL = function(...a) {
|
||||||
|
const img = _tDU.apply(this, a);
|
||||||
|
if (img.includes('image/png') && img.length > 5000) { const n = btoa(String.fromCharCode(Math.floor(Math.random()*256))); return img.slice(0,-100)+n+img.slice(-100); }
|
||||||
|
return img;
|
||||||
|
};
|
||||||
|
const _gFD = AnalyserNode.prototype.getFloatFrequencyData;
|
||||||
|
if (_gFD) { AnalyserNode.prototype.getFloatFrequencyData = function(a) { _gFD.call(this,a); for(let i=0;i<a.length;i++) a[i]+=(Math.random()-0.5)*0.02; }; }
|
||||||
|
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
|
||||||
|
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
|
||||||
|
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5].map(i => ({name:['Chrome PDF Plugin','Chrome PDF Viewer','Native Client','Widevine Content Decryption Module','Chromium PDF Viewer'][i-1],filename:'internal-pdf-viewer',length:1,description:['Portable Document Format','','Native Client Executable','Enables Widevine licenses',''][i-1]})) });
|
||||||
|
Object.defineProperty(navigator, 'mimeTypes', { get: () => [{type:'application/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]},{type:'text/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]}] });"""
|
||||||
|
|
||||||
|
|
||||||
def kw_match(text: str) -> bool:
|
def kw_match(text: str) -> bool:
|
||||||
t = text.lower()
|
t = text.lower()
|
||||||
return any(kw in t for kw in BROAD_KEYWORDS)
|
return any(kw in t for kw in BROAD_KEYWORDS)
|
||||||
|
|
||||||
|
def is_request(text: str) -> bool:
|
||||||
|
t = text.lower()
|
||||||
|
return any(re.search(p, t) for p in REQUEST_PATTERNS)
|
||||||
|
|
||||||
|
def is_offer(text: str) -> bool:
|
||||||
|
t = text.lower()
|
||||||
|
return any(re.search(p, t) for p in OFFER_PATTERNS)
|
||||||
|
|
||||||
FB_SEARCHES = [
|
FB_SEARCHES = [
|
||||||
"looking for web developer",
|
"looking for web developer",
|
||||||
"need a website designed",
|
"need a website designed",
|
||||||
@@ -50,10 +357,19 @@ FB_SEARCHES = [
|
|||||||
"looking for someone to create website",
|
"looking for someone to create website",
|
||||||
"need ecommerce website built",
|
"need ecommerce website built",
|
||||||
"want to hire web developer",
|
"want to hire web developer",
|
||||||
"website quote please",
|
|
||||||
"need wordpress website",
|
"need wordpress website",
|
||||||
"I need a website for my business",
|
"I need a website for my business",
|
||||||
"need a site for my business",
|
"need a site for my business",
|
||||||
|
"looking for website designer South Africa",
|
||||||
|
"need website for my small business",
|
||||||
|
"who can build me a website",
|
||||||
|
"want to create a website for my business",
|
||||||
|
"looking for affordable web design",
|
||||||
|
"need a web developer South Africa",
|
||||||
|
"help me build a website",
|
||||||
|
"need someone to design my website",
|
||||||
|
"looking for web designer near me",
|
||||||
|
"need a website for my startup",
|
||||||
]
|
]
|
||||||
|
|
||||||
VIEWPORTS = [
|
VIEWPORTS = [
|
||||||
@@ -136,52 +452,121 @@ def _parse_fb_date(block: list[str]) -> str:
|
|||||||
pass
|
pass
|
||||||
return datetime.now().strftime('%Y-%m-%d')
|
return datetime.now().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|
||||||
lines = [l.strip() for l in raw.split('\n')]
|
|
||||||
blocks = []
|
|
||||||
cur = []
|
|
||||||
fb_run = 0
|
|
||||||
for l in lines:
|
|
||||||
if 'facebook' in l.lower():
|
|
||||||
fb_run += 1
|
|
||||||
if cur and fb_run >= 2:
|
|
||||||
blocks.append(cur)
|
|
||||||
cur = []
|
|
||||||
else:
|
|
||||||
fb_run = 0
|
|
||||||
if l and len(l) >= 15:
|
|
||||||
words = re.findall(r'[A-Za-z]{2,}', l)
|
|
||||||
if len(words) >= 2:
|
|
||||||
cur.append(l)
|
|
||||||
if cur:
|
|
||||||
blocks.append(cur)
|
|
||||||
|
|
||||||
|
def _is_within_days(date_str: str, max_days: int = 3) -> bool:
|
||||||
|
"""Check if date is within max_days from now. Empty/unparseable = keep."""
|
||||||
|
if not date_str:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(date_str.strip(), '%Y-%m-%d')
|
||||||
|
return (datetime.now() - dt).days <= max_days
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_fb_text(text: str) -> str:
|
||||||
|
cleaned_lines = []
|
||||||
|
for l in text.split('\n'):
|
||||||
|
stripped = l.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if len(stripped) < 3:
|
||||||
|
continue
|
||||||
|
if all(not c.isalpha() for c in stripped):
|
||||||
|
continue
|
||||||
|
cleaned_lines.append(stripped)
|
||||||
|
return '\n'.join(cleaned_lines)
|
||||||
|
|
||||||
|
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
|
||||||
posts = []
|
posts = []
|
||||||
seen_texts = set()
|
seen_texts = set()
|
||||||
for block in blocks:
|
for el in elements:
|
||||||
if not block:
|
raw_text = el.get('text', '')
|
||||||
|
if len(raw_text) < 40:
|
||||||
continue
|
continue
|
||||||
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
|
text = _clean_fb_text(raw_text)
|
||||||
if not kw_indices:
|
if len(text) < 40:
|
||||||
continue
|
continue
|
||||||
i = kw_indices[0]
|
if is_offer(text):
|
||||||
start = max(0, i - 2)
|
|
||||||
end = min(len(block), i + 5)
|
|
||||||
snippet = ' '.join(block[start:end])
|
|
||||||
if len(snippet) < 40:
|
|
||||||
continue
|
continue
|
||||||
dekey = snippet[:80]
|
lines = text.split('\n')
|
||||||
|
dekey = text[:80]
|
||||||
if dekey in seen_texts:
|
if dekey in seen_texts:
|
||||||
continue
|
continue
|
||||||
seen_texts.add(dekey)
|
seen_texts.add(dekey)
|
||||||
|
request_score = 2 if is_request(text) else 0
|
||||||
|
post_url = el.get('url') or base_url
|
||||||
|
|
||||||
|
# Prefer JS-extracted date, fall back to text parsing
|
||||||
|
js_date = (el.get('date') or '').strip()
|
||||||
|
if js_date:
|
||||||
|
# Try ISO datetime from <time datetime>
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
|
||||||
|
date_str = dt.strftime('%Y-%m-%d')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
date_str = _parse_fb_date([js_date])
|
||||||
|
else:
|
||||||
|
date_str = _parse_fb_date(lines)
|
||||||
|
|
||||||
|
posts.append({
|
||||||
|
"title": text[:300],
|
||||||
|
"content": text[:1000],
|
||||||
|
"author": el.get('author', ''),
|
||||||
|
"url": post_url,
|
||||||
|
"source": "facebook",
|
||||||
|
"date": date_str,
|
||||||
|
"_score": request_score,
|
||||||
|
})
|
||||||
|
posts.sort(key=lambda p: p.get('_score', 0), reverse=True)
|
||||||
|
for p in posts:
|
||||||
|
p.pop('_score', None)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
||||||
|
lines = [l.strip() for l in raw.split('\n') if l.strip() and len(l.strip()) >= 15]
|
||||||
|
posts = []
|
||||||
|
seen_texts = set()
|
||||||
|
cur = []
|
||||||
|
for l in lines:
|
||||||
|
words = re.findall(r'[A-Za-z]{2,}', l)
|
||||||
|
if len(words) < 2:
|
||||||
|
continue
|
||||||
|
lower = l.lower()
|
||||||
|
if any(kw in lower for kw in BROAD_KEYWORDS) and not is_offer(l):
|
||||||
|
if cur:
|
||||||
|
snippet = ' '.join(cur[-3:] + [l])
|
||||||
|
if len(snippet) >= 40 and not is_offer(snippet):
|
||||||
|
dekey = snippet[:80]
|
||||||
|
if dekey not in seen_texts:
|
||||||
|
seen_texts.add(dekey)
|
||||||
posts.append({
|
posts.append({
|
||||||
"title": snippet[:300],
|
"title": snippet[:300],
|
||||||
"content": snippet[:1000],
|
"content": snippet[:1000],
|
||||||
"author": block[start] if start < i else '',
|
"author": '',
|
||||||
"url": url,
|
"url": url,
|
||||||
"source": "facebook",
|
"source": "facebook",
|
||||||
"date": _parse_fb_date(block),
|
"date": _parse_fb_date(cur + [l]),
|
||||||
})
|
})
|
||||||
|
cur = []
|
||||||
|
else:
|
||||||
|
if not is_offer(l):
|
||||||
|
snippet = l
|
||||||
|
if len(snippet) >= 40:
|
||||||
|
dekey = snippet[:80]
|
||||||
|
if dekey not in seen_texts:
|
||||||
|
seen_texts.add(dekey)
|
||||||
|
posts.append({
|
||||||
|
"title": snippet[:300],
|
||||||
|
"content": snippet[:1000],
|
||||||
|
"author": '',
|
||||||
|
"url": url,
|
||||||
|
"source": "facebook",
|
||||||
|
"date": _parse_fb_date([l]),
|
||||||
|
})
|
||||||
|
cur.append(l)
|
||||||
|
if len(cur) > 10:
|
||||||
|
cur.pop(0)
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
async def human_scroll(page, steps: int = None, total_delay: float = None):
|
async def human_scroll(page, steps: int = None, total_delay: float = None):
|
||||||
@@ -191,6 +576,13 @@ async def human_scroll(page, steps: int = None, total_delay: float = None):
|
|||||||
for i in range(steps):
|
for i in range(steps):
|
||||||
scroll_dist = random.randint(200, 600)
|
scroll_dist = random.randint(200, 600)
|
||||||
await page.evaluate(f"window.scrollBy(0, {scroll_dist})")
|
await page.evaluate(f"window.scrollBy(0, {scroll_dist})")
|
||||||
|
try:
|
||||||
|
vp = await page.evaluate('({w: window.innerWidth, h: window.innerHeight})')
|
||||||
|
x = random.randint(100, vp['w'] - 100)
|
||||||
|
y = random.randint(100, vp['h'] - 100)
|
||||||
|
await page.mouse.move(x, y, steps=random.randint(15, 35))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
await page.wait_for_timeout(int(step_delay * 1000))
|
await page.wait_for_timeout(int(step_delay * 1000))
|
||||||
if random.random() < 0.3:
|
if random.random() < 0.3:
|
||||||
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 300)})")
|
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 300)})")
|
||||||
@@ -212,15 +604,95 @@ async def random_idle(page):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def search_facebook(page, query: str) -> list[dict]:
|
async def _get_article_elements(page) -> list[dict]:
|
||||||
|
return await page.evaluate('''() => {
|
||||||
|
const results = [];
|
||||||
|
const seenTexts = new Set();
|
||||||
|
const selectors = [
|
||||||
|
'div[role="article"]',
|
||||||
|
'div[role="feed"] > div',
|
||||||
|
'div.x1yztbdb',
|
||||||
|
'div[data-pagelet]',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const els = document.querySelectorAll(sel);
|
||||||
|
for (const el of els) {
|
||||||
|
const text = (el.innerText || '').trim();
|
||||||
|
if (text.length < 40) continue;
|
||||||
|
const key = text.substring(0, 80);
|
||||||
|
if (seenTexts.has(key)) continue;
|
||||||
|
seenTexts.add(key);
|
||||||
|
|
||||||
|
// --- Publisher name ---
|
||||||
|
let author = '';
|
||||||
|
const nameEl = el.querySelector('h4, strong, a[role="link"]');
|
||||||
|
if (nameEl) {
|
||||||
|
author = (nameEl.innerText || '').trim();
|
||||||
|
}
|
||||||
|
if (!author) {
|
||||||
|
const links = el.querySelectorAll('a');
|
||||||
|
for (const a of links) {
|
||||||
|
const t = (a.innerText || '').trim();
|
||||||
|
if (t && t.length > 1 && t.length < 60 && /^[A-Za-zÀ-ÿ]/.test(t) && !t.includes('facebook') && !t.includes('/')) {
|
||||||
|
author = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Post URL ---
|
||||||
|
let postUrl = '';
|
||||||
|
for (const a of el.querySelectorAll('a')) {
|
||||||
|
const h = (a.getAttribute('href') || '');
|
||||||
|
if (h.includes('/posts/') || h.includes('/photo/') || h.includes('/video/') || h.includes('/groups/') || h.includes('/permalink/')) {
|
||||||
|
postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date from <time datetime> ---
|
||||||
|
let date = '';
|
||||||
|
const timeEl = el.querySelector('time');
|
||||||
|
if (timeEl) {
|
||||||
|
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ text, author, url: postUrl, date });
|
||||||
|
}
|
||||||
|
if (results.length > 0) break;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}''')
|
||||||
|
|
||||||
|
async def _ensure_page(page, context):
|
||||||
|
try:
|
||||||
|
await page.evaluate('1')
|
||||||
|
return page
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Page was closed mid-scrape, creating a fresh page")
|
||||||
|
page = await context.new_page()
|
||||||
|
try:
|
||||||
|
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
|
||||||
|
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Google navigation failed during page recreation")
|
||||||
|
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
||||||
|
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||||
|
return page
|
||||||
|
|
||||||
|
async def search_facebook(page, context, query: str):
|
||||||
|
page = await _ensure_page(page, context)
|
||||||
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector('div[role="article"], div[role="feed"]', timeout=15000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||||
|
|
||||||
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
|
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
|
||||||
|
|
||||||
# Accidental overscroll: 10% chance to jump to bottom and back
|
|
||||||
if random.random() < 0.1:
|
if random.random() < 0.1:
|
||||||
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||||
@@ -234,11 +706,34 @@ async def search_facebook(page, query: str) -> list[dict]:
|
|||||||
if random.random() < 0.3:
|
if random.random() < 0.3:
|
||||||
await random_idle(page)
|
await random_idle(page)
|
||||||
|
|
||||||
|
raw_articles = await _get_article_elements(page)
|
||||||
|
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
|
||||||
|
if not posts:
|
||||||
raw = await page.evaluate('document.body.innerText')
|
raw = await page.evaluate('document.body.innerText')
|
||||||
|
posts = _extract_posts_from_text(raw, url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Facebook search failed: %s", e)
|
logger.warning("Facebook search '%s' failed: %s", query, e)
|
||||||
return []
|
return page, []
|
||||||
return _extract_posts_from_text(raw, url)
|
return page, posts
|
||||||
|
|
||||||
|
DETECTION_SIGNALS = [
|
||||||
|
'/checkpoint/', '/login.php?', 'action=security_check',
|
||||||
|
'unusual activity', 'suspicious login', 'suspicious activity',
|
||||||
|
'temporarily blocked', 'temporarily restricted',
|
||||||
|
'confirm your identity', 'enter the code we sent',
|
||||||
|
'we noticed unusual', 'help us confirm',
|
||||||
|
'security check', 'challenge', 'please verify',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def check_detection_signals(page_url: str, page_text: str = '') -> str | None:
|
||||||
|
"""Check page for Facebook detection signals. Returns signal text or None."""
|
||||||
|
combined = (page_url + ' ' + page_text).lower()
|
||||||
|
for signal in DETECTION_SIGNALS:
|
||||||
|
if signal in combined:
|
||||||
|
return signal
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def cleanup_chrome():
|
def cleanup_chrome():
|
||||||
import subprocess, signal
|
import subprocess, signal
|
||||||
@@ -248,88 +743,188 @@ def cleanup_chrome():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
|
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
|
||||||
cleanup_chrome()
|
"""Dispatcher — Firefox primary, browser-use Agent fallback."""
|
||||||
fb_cookies = await get_fb_cookies(profile_path)
|
effective_path = profile_path or FX_PROFILE
|
||||||
if not fb_cookies:
|
browser_type = detect_browser_from_profile(effective_path)
|
||||||
logger.warning("No Facebook cookies available")
|
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No cookies available"}
|
if not browser_type and CHROME_PROFILE:
|
||||||
|
browser_type = detect_browser_from_profile(CHROME_PROFILE)
|
||||||
|
effective_path = CHROME_PROFILE
|
||||||
|
|
||||||
|
logger.info("Detected browser: %s (profile: %s)", browser_type or "none", effective_path)
|
||||||
|
|
||||||
|
# Firefox primary (raw Playwright, stealth)
|
||||||
|
if browser_type == "firefox":
|
||||||
|
result = await _scrape_with_firefox(effective_path, force)
|
||||||
|
if result.get("success") or not result.get("flagged"):
|
||||||
|
return result
|
||||||
|
logger.warning("Firefox path failed (%s), falling back to Agent", result.get("flag_reason", "unknown"))
|
||||||
|
return await _scrape_with_agent(force)
|
||||||
|
|
||||||
|
# CHROME_PROFILE set or no profile → Agent
|
||||||
|
if browser_type == "chromium":
|
||||||
|
return await _scrape_with_agent(force)
|
||||||
|
|
||||||
|
# No profile at all → try Agent (fresh Chromium)
|
||||||
|
return await _scrape_with_agent(force)
|
||||||
|
|
||||||
|
|
||||||
|
async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
|
||||||
|
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
|
||||||
|
if not profile_path:
|
||||||
|
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
|
||||||
|
|
||||||
|
profile_dir = None
|
||||||
try:
|
try:
|
||||||
|
profile_dir = copy_firefox_profile(profile_path)
|
||||||
async with async_playwright() as pw:
|
async with async_playwright() as pw:
|
||||||
browser = await pw.chromium.launch(
|
context = await pw.firefox.launch_persistent_context(
|
||||||
|
user_data_dir=profile_dir,
|
||||||
headless=True,
|
headless=True,
|
||||||
args=['--disable-blink-features=AutomationControlled']
|
firefox_user_prefs={
|
||||||
)
|
"dom.webdriver.enabled": False,
|
||||||
viewport = random.choice(VIEWPORTS)
|
"dom.webdriver.timeout": 0,
|
||||||
context = await browser.new_context(
|
"useAutomationExtension": False,
|
||||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
"privacy.trackingprotection.enabled": False,
|
||||||
viewport=viewport,
|
"privacy.trackingprotection.fingerprinting.enabled": False,
|
||||||
|
"privacy.trackingprotection.cryptomining.enabled": False,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
pages = context.pages
|
||||||
|
page = pages[0] if pages else await context.new_page()
|
||||||
|
await context.add_init_script(firefox_init_script())
|
||||||
|
|
||||||
await context.add_init_script("""
|
|
||||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
|
||||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
|
||||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
|
||||||
window.chrome = { runtime: {} };
|
|
||||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
|
||||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
|
||||||
""")
|
|
||||||
for c in fb_cookies:
|
|
||||||
try:
|
try:
|
||||||
await context.add_cookies([c])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
|
|
||||||
|
|
||||||
page = await context.new_page()
|
|
||||||
# Navigate through google first for a legitimate Referer header
|
|
||||||
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
|
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Google navigation failed, trying Facebook directly")
|
||||||
|
|
||||||
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||||
|
|
||||||
url = page.url
|
url = page.url
|
||||||
if '/login' in url.lower():
|
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
|
||||||
logger.warning("Facebook login page detected — account flagged")
|
det = check_detection_signals(url, page_text)
|
||||||
await browser.close()
|
if det or '/login' in url.lower():
|
||||||
return {"success": False, "leads": [], "flagged": True, "flag_reason": "login_page", "error": "Facebook login page detected"}
|
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"}
|
||||||
|
|
||||||
# Browse feed like a human before searching
|
|
||||||
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))
|
||||||
|
|
||||||
# False start: 30% chance to idle and leave without scraping (skipped when force=true)
|
|
||||||
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 browser.close()
|
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 = []
|
all_posts = []
|
||||||
searches = random.sample(FB_SEARCHES, k=random.randint(3, 6))
|
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
|
||||||
for i, query in enumerate(searches):
|
for i, query in enumerate(searches):
|
||||||
try:
|
page, posts = await search_facebook(page, context, query)
|
||||||
posts = await search_facebook(page, query)
|
|
||||||
all_posts.extend(posts)
|
all_posts.extend(posts)
|
||||||
# Between searches: random break with possible small scroll
|
if not posts:
|
||||||
|
continue
|
||||||
if random.random() < 0.4:
|
if random.random() < 0.4:
|
||||||
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
|
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
|
||||||
delay = random.uniform(8, 25)
|
delay = random.uniform(8, 25)
|
||||||
await page.wait_for_timeout(int(delay * 1000))
|
await page.wait_for_timeout(int(delay * 1000))
|
||||||
# Tab switch: 15% chance after 2nd-3rd search
|
if i == random.randint(0, 1) and random.random() < 0.15:
|
||||||
if i == random.randint(1, 2) and random.random() < 0.15:
|
|
||||||
new_page = await context.new_page()
|
new_page = await context.new_page()
|
||||||
await new_page.goto('https://www.messenger.com/', wait_until='domcontentloaded', timeout=15000)
|
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))
|
await new_page.wait_for_timeout(random.randint(3000, 8000))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
await new_page.close()
|
await new_page.close()
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
page = await _ensure_page(page, context)
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Facebook search '%s' failed: %s", query, e)
|
|
||||||
|
|
||||||
# Idle before closing — human would pause
|
|
||||||
if random.random() < 0.5:
|
if random.random() < 0.5:
|
||||||
await page.wait_for_timeout(random.randint(3000, 10000))
|
await page.wait_for_timeout(random.randint(3000, 10000))
|
||||||
|
|
||||||
|
await context.close()
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
deduped = []
|
||||||
|
for p in all_posts:
|
||||||
|
key = p.get('content', '')[:100]
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(p)
|
||||||
|
|
||||||
|
# Filter to last 3 days only
|
||||||
|
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
|
||||||
|
|
||||||
|
leads = deduped[:20]
|
||||||
|
if leads:
|
||||||
|
leads = await classify_leads(leads)
|
||||||
|
|
||||||
|
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Firefox scrape failed: %s", e)
|
||||||
|
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
|
||||||
|
finally:
|
||||||
|
if profile_dir and os.path.exists(profile_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(profile_dir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _scrape_with_agent(force: bool = False) -> dict:
|
||||||
|
"""Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
|
||||||
|
cleanup_chrome()
|
||||||
|
profile_dir = None
|
||||||
|
try:
|
||||||
|
user_data_dir = None
|
||||||
|
if CHROME_PROFILE:
|
||||||
|
profile_dir = copy_chrome_profile(CHROME_PROFILE)
|
||||||
|
user_data_dir = profile_dir
|
||||||
|
|
||||||
|
browser = Browser(
|
||||||
|
headless=True,
|
||||||
|
args=CHROME_LAUNCH_ARGS,
|
||||||
|
user_data_dir=user_data_dir,
|
||||||
|
allowed_domains=["*.facebook.com", "*.messenger.com", "www.google.com"],
|
||||||
|
)
|
||||||
|
await browser.start()
|
||||||
|
|
||||||
|
all_posts = []
|
||||||
|
for query in random.sample(FB_SEARCHES, k=random.randint(2, 4)):
|
||||||
|
agent = Agent(
|
||||||
|
task=f"""You are logged into Facebook. Do the following:
|
||||||
|
1. Navigate to facebook.com and make sure you are on the homepage
|
||||||
|
2. Use the Facebook search to find: {query}
|
||||||
|
3. Scroll through the results
|
||||||
|
4. For each relevant post, extract and list:
|
||||||
|
- The publisher/author name (who posted it)
|
||||||
|
- The post text content
|
||||||
|
- The post URL (if visible)
|
||||||
|
- The post date
|
||||||
|
5. ONLY include posts from the last 3 days
|
||||||
|
6. Collect as many posts as you can (aim for 5-10 per search)
|
||||||
|
|
||||||
|
When done, return the data as a JSON list with keys: content, author, url, date.""",
|
||||||
|
llm=make_ollama(num_ctx=32000, temperature=0.3),
|
||||||
|
browser=browser,
|
||||||
|
max_actions_per_step=3,
|
||||||
|
use_vision=False,
|
||||||
|
max_failures=6,
|
||||||
|
step_timeout=180,
|
||||||
|
)
|
||||||
|
history = await agent.run()
|
||||||
|
result = history.final_result() or ""
|
||||||
|
posts = extract_agent_posts(result, "")
|
||||||
|
all_posts.extend(posts)
|
||||||
|
|
||||||
|
if random.random() < 0.3:
|
||||||
|
await asyncio.sleep(random.randint(5, 15))
|
||||||
|
|
||||||
await browser.close()
|
await browser.close()
|
||||||
|
|
||||||
seen = set()
|
seen = set()
|
||||||
@@ -340,14 +935,23 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
deduped.append(p)
|
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]
|
leads = deduped[:20]
|
||||||
if leads:
|
if leads:
|
||||||
leads = await classify_leads(leads)
|
leads = await classify_leads(leads)
|
||||||
|
|
||||||
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:
|
||||||
logger.error("Facebook scrape failed: %s", e)
|
logger.error("Agent scrape failed: %s", e)
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
|
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
|
||||||
|
finally:
|
||||||
|
if profile_dir and os.path.isdir(profile_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(profile_dir, ignore_errors=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to clean up Chrome profile %s: %s", profile_dir, e)
|
||||||
|
|
||||||
async def ask_ollama(prompt: str) -> str:
|
async def ask_ollama(prompt: str) -> str:
|
||||||
import httpx
|
import httpx
|
||||||
@@ -417,16 +1021,18 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
"need a website", "my website", "business website",
|
"need a website", "my website", "business website",
|
||||||
"need a designer", "help with my website",
|
"need a designer", "help with my website",
|
||||||
"redesign", "update my website",
|
"redesign", "update my website",
|
||||||
|
"looking for", "need a", "need an", "looking to",
|
||||||
|
"need someone", "hire a", "want someone",
|
||||||
|
"need help with", "would like", "build me",
|
||||||
|
"design my", "make me a", "create my",
|
||||||
]
|
]
|
||||||
filtered = []
|
filtered = []
|
||||||
for r in results:
|
for r in results:
|
||||||
t = r['title'].lower()
|
t = r['title'].lower()
|
||||||
if not any(kw in t for kw in strict_keywords):
|
if not any(kw in t for kw in strict_keywords):
|
||||||
continue
|
continue
|
||||||
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
|
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
|
||||||
'affordable web', 'web hosting', 'free website',
|
'affordable web design package', 'web hosting']):
|
||||||
'limited time', 'special offer', 'sign up now',
|
|
||||||
'offer']):
|
|
||||||
continue
|
continue
|
||||||
filtered.append(r)
|
filtered.append(r)
|
||||||
return filtered[:10]
|
return filtered[:10]
|
||||||
@@ -436,6 +1042,28 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.post("/agent/run")
|
||||||
|
async def agent_run(task: str = Body(..., embed=True)):
|
||||||
|
"""Run a browser-use Agent with ChatOllama (free/local)."""
|
||||||
|
try:
|
||||||
|
browser = Browser(headless=True, args=CHROME_LAUNCH_ARGS)
|
||||||
|
await browser.start()
|
||||||
|
agent = Agent(
|
||||||
|
task=task,
|
||||||
|
llm=make_ollama(num_ctx=32000, temperature=0.3),
|
||||||
|
browser=browser,
|
||||||
|
use_vision=False,
|
||||||
|
max_actions_per_step=5,
|
||||||
|
max_failures=8,
|
||||||
|
step_timeout=180,
|
||||||
|
)
|
||||||
|
history = await agent.run()
|
||||||
|
await browser.close()
|
||||||
|
return {"success": True, "result": history.final_result()}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Agent run failed: %s", e)
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
@app.post("/scrape/facebook")
|
@app.post("/scrape/facebook")
|
||||||
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
|
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
|
||||||
result = await scrape_facebook(profile_path, force)
|
result = await scrape_facebook(profile_path, force)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
create table if not exists contacts (
|
||||||
|
id uuid default gen_random_uuid() primary key,
|
||||||
|
user_id uuid references auth.users(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
phone text not null,
|
||||||
|
avatar_url text,
|
||||||
|
created_at timestamp default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table contacts enable row level security;
|
||||||
|
|
||||||
|
create policy "Users see own contacts"
|
||||||
|
on contacts for select
|
||||||
|
using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy "Users insert own contacts"
|
||||||
|
on contacts for insert
|
||||||
|
with check (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy "Users delete own contacts"
|
||||||
|
on contacts for delete
|
||||||
|
using (auth.uid() = user_id);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- CRM Security Architecture Upgrade
|
||||||
|
-- Internal Company CRM — Prioritizes control, recovery, and maintainability
|
||||||
|
-- ============================================================================
|
||||||
|
-- Implements:
|
||||||
|
-- • Dual password storage (bcrypt + pgcrypto AES reversible)
|
||||||
|
-- • SUPER_ADMIN master key recovery system
|
||||||
|
-- • Row Level Security (RLS) on CRM tables
|
||||||
|
-- • Database export logging
|
||||||
|
-- • Backup logging
|
||||||
|
-- • Immutable admin action tracking
|
||||||
|
-- • SQL injection defense (parameterized queries enforced app-side)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 1. DUAL PASSWORD STORAGE
|
||||||
|
-- ============================================================================
|
||||||
|
-- password_hash (bcrypt) — used for normal login authentication
|
||||||
|
-- password_encrypted (AES) — reversible encryption for emergency credential recovery
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 2. SUPER_ADMIN MASTER KEY SYSTEM
|
||||||
|
-- ============================================================================
|
||||||
|
-- Stores the master decryption key used with pgp_sym_encrypt/pgp_sym_decrypt.
|
||||||
|
-- Only accessible by SUPER_ADMIN role via security definer functions.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS master_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
key_name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
key_value TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_master_keys_active ON master_keys(key_name) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 3. DATABASE EXPORT LOGGING
|
||||||
|
-- ============================================================================
|
||||||
|
-- Tracks all database exports and SQL dumps performed by SUPER_ADMIN.
|
||||||
|
-- Immutable — never allow deletion or update.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS database_export_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
exported_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
export_type VARCHAR(50) NOT NULL,
|
||||||
|
file_name VARCHAR(500) NOT NULL,
|
||||||
|
file_size_bytes BIGINT,
|
||||||
|
record_count INT,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
exported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_export_logs_user ON database_export_logs(exported_by);
|
||||||
|
CREATE INDEX idx_export_logs_time ON database_export_logs(exported_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE database_export_logs IS 'Immutable audit of all database exports. Never DELETE or UPDATE rows.';
|
||||||
|
COMMENT ON COLUMN database_export_logs.export_type IS 'pg_dump, csv_export, full_backup, selective_export';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 4. BACKUP LOGGING
|
||||||
|
-- ============================================================================
|
||||||
|
-- Records every automated or manual database backup.
|
||||||
|
-- Retention: minimum 30 days of backup history.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
backup_type VARCHAR(20) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
file_name VARCHAR(500),
|
||||||
|
file_size_bytes BIGINT,
|
||||||
|
pg_dump_exit_code INT,
|
||||||
|
error_message TEXT,
|
||||||
|
checksum VARCHAR(64),
|
||||||
|
verification_status VARCHAR(20),
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
duration_seconds INT,
|
||||||
|
retention_days INT NOT NULL DEFAULT 30,
|
||||||
|
triggered_by UUID REFERENCES users(id),
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_backup_logs_time ON backup_logs(started_at DESC);
|
||||||
|
CREATE INDEX idx_backup_logs_status ON backup_logs(status);
|
||||||
|
CREATE INDEX idx_backup_logs_type ON backup_logs(backup_type);
|
||||||
|
CREATE INDEX idx_backup_logs_completed ON backup_logs(status)
|
||||||
|
WHERE status = 'completed';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 5. AUDIT TRIGGER: Password changes
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
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), ''),
|
||||||
|
NULLIF(current_setting('app.current_ip', true), '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_password_change ON users;
|
||||||
|
CREATE TRIGGER trg_audit_password_change
|
||||||
|
AFTER UPDATE OF password_hash ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_password_change();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 6. AUDIT TRIGGER: User creation
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION audit_user_creation()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
table_name, record_id, action, new_data, changed_by
|
||||||
|
) VALUES (
|
||||||
|
'users',
|
||||||
|
NEW.id,
|
||||||
|
'CREATE',
|
||||||
|
jsonb_build_object(
|
||||||
|
'username', NEW.username,
|
||||||
|
'email', NEW.email,
|
||||||
|
'first_name', NEW.first_name,
|
||||||
|
'last_name', NEW.last_name,
|
||||||
|
'is_active', NEW.is_active
|
||||||
|
),
|
||||||
|
NEW.created_by
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_user_create ON users;
|
||||||
|
CREATE TRIGGER trg_audit_user_create
|
||||||
|
AFTER INSERT ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_user_creation();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 7. AUDIT TRIGGER: Database exports
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION audit_database_export()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
table_name, record_id, action, new_data, changed_by
|
||||||
|
) VALUES (
|
||||||
|
'database_export_logs',
|
||||||
|
NEW.id,
|
||||||
|
'CREATE',
|
||||||
|
jsonb_build_object(
|
||||||
|
'export_type', NEW.export_type,
|
||||||
|
'file_name', NEW.file_name,
|
||||||
|
'file_size_bytes', NEW.file_size_bytes,
|
||||||
|
'record_count', NEW.record_count
|
||||||
|
),
|
||||||
|
NEW.exported_by
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_database_export ON database_export_logs;
|
||||||
|
CREATE TRIGGER trg_audit_database_export
|
||||||
|
AFTER INSERT ON database_export_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_database_export();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 8. AUDIT TRIGGER: Login/logout already exists via login_attempts trigger
|
||||||
|
-- (trg_audit_login in 001_schema.sql)
|
||||||
|
-- This enhances it to also track session-level events.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
|
||||||
|
CREATE TRIGGER trg_audit_login
|
||||||
|
AFTER INSERT ON login_attempts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_login();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 9. ROW LEVEL SECURITY
|
||||||
|
-- ============================================================================
|
||||||
|
-- RLS policies enforce data visibility per role:
|
||||||
|
-- SALES_USER → only sees records assigned to them
|
||||||
|
-- ADMIN → sees operational records for investigation
|
||||||
|
-- SUPER_ADMIN → sees everything
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Helper function to get current user's role hierarchy level
|
||||||
|
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
|
||||||
|
RETURNS INT AS $$
|
||||||
|
DECLARE
|
||||||
|
uid UUID;
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
uid := NULLIF(current_setting('app.current_user_id', true), '')::UUID;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF uid IS NULL THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN (
|
||||||
|
SELECT MIN(r.hierarchy_level)
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = uid
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Helper function to get current user's ID from session setting
|
||||||
|
CREATE OR REPLACE FUNCTION current_user_id()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- SALES_USER level = 3, ADMIN level = 2, SUPER_ADMIN level = 1
|
||||||
|
|
||||||
|
-- ── customers ──
|
||||||
|
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_customers_select ON customers;
|
||||||
|
CREATE POLICY rls_customers_select ON customers
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN see all
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_customers_insert ON customers;
|
||||||
|
CREATE POLICY rls_customers_insert ON customers
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN can assign any owner
|
||||||
|
OR owner_id = current_user_id() -- SALES_USER can only assign to self
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_customers_update ON customers;
|
||||||
|
CREATE POLICY rls_customers_update ON customers
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR (
|
||||||
|
owner_id = current_user_id()
|
||||||
|
AND owner_id = current_user_id() -- SALES_USER cannot reassign ownership
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_customers_delete ON customers;
|
||||||
|
CREATE POLICY rls_customers_delete ON customers
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── leads ──
|
||||||
|
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_leads_select ON leads;
|
||||||
|
CREATE POLICY rls_leads_select ON leads
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_leads_insert ON leads;
|
||||||
|
CREATE POLICY rls_leads_insert ON leads
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_leads_update ON leads;
|
||||||
|
CREATE POLICY rls_leads_update ON leads
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_leads_delete ON leads;
|
||||||
|
CREATE POLICY rls_leads_delete ON leads
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── opportunities ──
|
||||||
|
ALTER TABLE opportunities ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_opportunities_select ON opportunities;
|
||||||
|
CREATE POLICY rls_opportunities_select ON opportunities
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_opportunities_insert ON opportunities;
|
||||||
|
CREATE POLICY rls_opportunities_insert ON opportunities
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_opportunities_update ON opportunities;
|
||||||
|
CREATE POLICY rls_opportunities_update ON opportunities
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_opportunities_delete ON opportunities;
|
||||||
|
CREATE POLICY rls_opportunities_delete ON opportunities
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── communications ──
|
||||||
|
ALTER TABLE communications ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_communications_select ON communications;
|
||||||
|
CREATE POLICY rls_communications_select ON communications
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2
|
||||||
|
OR created_by = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_communications_insert ON communications;
|
||||||
|
CREATE POLICY rls_communications_insert ON communications
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_communications_delete ON communications;
|
||||||
|
CREATE POLICY rls_communications_delete ON communications
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── tasks ──
|
||||||
|
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_tasks_select ON tasks;
|
||||||
|
CREATE POLICY rls_tasks_select ON tasks
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
OR assigned_by = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_tasks_update ON tasks;
|
||||||
|
CREATE POLICY rls_tasks_update ON tasks
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
OR assigned_by = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 10. IMMUTABLE AUDIT LOG PROTECTION
|
||||||
|
-- ============================================================================
|
||||||
|
-- Prevent deletion or update of audit_logs at the database level.
|
||||||
|
-- Even SUPER_ADMIN cannot delete historical audit records.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION prevent_audit_mutation()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'IMMUTABLE: audit_logs cannot be modified or deleted. All changes are permanently recorded.';
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_audit_delete ON audit_logs;
|
||||||
|
CREATE TRIGGER trg_prevent_audit_delete
|
||||||
|
BEFORE DELETE ON audit_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_audit_mutation();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_audit_update ON audit_logs;
|
||||||
|
CREATE TRIGGER trg_prevent_audit_update
|
||||||
|
BEFORE UPDATE ON audit_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_audit_mutation();
|
||||||
|
|
||||||
|
-- Same protection for database_export_logs
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_export_delete ON database_export_logs;
|
||||||
|
CREATE TRIGGER trg_prevent_export_delete
|
||||||
|
BEFORE DELETE ON database_export_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_audit_mutation();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_export_update ON database_export_logs;
|
||||||
|
CREATE TRIGGER trg_prevent_export_update
|
||||||
|
BEFORE UPDATE ON database_export_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_audit_mutation();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 11. ENCRYPTION FUNCTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
-- Uses pgcrypto's pgp_sym_encrypt / pgp_sym_decrypt with the master key.
|
||||||
|
-- Master key is stored in master_keys table, fetched by security definer functions.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Get the active master decryption key
|
||||||
|
-- Only callable by SUPER_ADMIN
|
||||||
|
CREATE OR REPLACE FUNCTION get_master_decryption_key()
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
key_value TEXT;
|
||||||
|
uid UUID;
|
||||||
|
user_level INT;
|
||||||
|
BEGIN
|
||||||
|
uid := current_user_id();
|
||||||
|
IF uid IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Not authenticated';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
user_level := current_user_hierarchy_level();
|
||||||
|
IF user_level IS NULL OR user_level > 1 THEN
|
||||||
|
RAISE EXCEPTION 'ACCESS DENIED: Only SUPER_ADMIN can access the master decryption key';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT mk.key_value INTO key_value
|
||||||
|
FROM master_keys mk
|
||||||
|
WHERE mk.is_active = TRUE
|
||||||
|
ORDER BY mk.created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
RETURN key_value;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Encrypt a plaintext password using the active master key
|
||||||
|
CREATE OR REPLACE FUNCTION encrypt_password(p_plaintext TEXT)
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
master_key TEXT;
|
||||||
|
BEGIN
|
||||||
|
SELECT key_value INTO master_key
|
||||||
|
FROM master_keys
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF master_key IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'No active master key found. Contact SUPER_ADMIN.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN encode(
|
||||||
|
pgp_sym_encrypt(p_plaintext, master_key, 'compress-algo=2, cipher-algo=aes256'),
|
||||||
|
'escape'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Decrypt a password that was encrypted with encrypt_password()
|
||||||
|
CREATE OR REPLACE FUNCTION decrypt_password(p_encrypted TEXT)
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
master_key TEXT;
|
||||||
|
BEGIN
|
||||||
|
master_key := get_master_decryption_key();
|
||||||
|
RETURN pgp_sym_decrypt(decode(p_encrypted, 'escape'), master_key);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 12. RLS BYPASS FOR SUPER_ADMIN
|
||||||
|
-- ============================================================================
|
||||||
|
-- SUPER_ADMIN bypasses all RLS. This function is used by triggers/policies
|
||||||
|
-- to check if the current user should bypass restrictions.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION is_super_admin()
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN COALESCE(current_user_hierarchy_level(), 999) <= 1;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 13. SEED DATA: Master key (for development/testing)
|
||||||
|
-- ============================================================================
|
||||||
|
-- In production, this key should be set via a secure deployment process.
|
||||||
|
-- The key is stored in the database and encrypted at rest by PostgreSQL.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
INSERT INTO master_keys (key_name, key_value, description, created_by)
|
||||||
|
SELECT
|
||||||
|
'MASTER_DECRYPTION_KEY',
|
||||||
|
encode(gen_random_bytes(32), 'hex'),
|
||||||
|
'Master key for reversible password encryption. Used with pgp_sym_encrypt/decrypt.',
|
||||||
|
'00000000-0000-0000-0000-000000000001'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM master_keys WHERE key_name = 'MASTER_DECRYPTION_KEY'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 14. UPDATE SEED DATA: Encrypt passwords for existing test accounts
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
UPDATE users SET password_encrypted = encrypt_password('SuperAdmin@2026')
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000001' AND password_encrypted IS NULL;
|
||||||
|
|
||||||
|
UPDATE users SET password_encrypted = encrypt_password('AdminAccess@2026')
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000002' AND password_encrypted IS NULL;
|
||||||
|
|
||||||
|
UPDATE users SET password_encrypted = encrypt_password('SalesAccess@2026')
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL;
|
||||||
|
|
||||||
|
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FUTURE REQUIREMENT NOTE:
|
||||||
|
-- If this system is ever exposed publicly, remove reversible password storage
|
||||||
|
-- immediately. Keep bcrypt only. Delete the password_encrypted column and
|
||||||
|
-- master_keys table. The MASTER_DECRYPTION_KEY must never be exposed externally.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Bug Reporting System
|
||||||
|
-- ============================================================================
|
||||||
|
-- Access rules:
|
||||||
|
-- ALL authenticated users can INSERT (submit bug reports)
|
||||||
|
-- Only ADMIN and SUPER_ADMIN can SELECT, UPDATE (view/manage)
|
||||||
|
-- SALES_USER and DEVELOPER cannot view bug_reports after submission
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bug_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
reported_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
severity VARCHAR(20) NOT NULL DEFAULT 'medium'
|
||||||
|
CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
||||||
|
page_url TEXT,
|
||||||
|
screenshot_url TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'open'
|
||||||
|
CHECK (status IN ('open', 'in_progress', 'resolved', 'closed')),
|
||||||
|
assigned_to UUID REFERENCES users(id),
|
||||||
|
resolution_notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_reported_by ON bug_reports(reported_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_status ON bug_reports(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_severity ON bug_reports(severity);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_assigned ON bug_reports(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_created ON bug_reports(created_at DESC);
|
||||||
|
|
||||||
|
-- RLS: Allow INSERT for all authenticated users
|
||||||
|
-- Allow SELECT/UPDATE only for ADMIN and SUPER_ADMIN
|
||||||
|
ALTER TABLE bug_reports ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS bug_reports_insert ON bug_reports;
|
||||||
|
CREATE POLICY bug_reports_insert ON bug_reports
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_id() IS NOT NULL
|
||||||
|
AND reported_by = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS bug_reports_select ON bug_reports;
|
||||||
|
CREATE POLICY bug_reports_select ON bug_reports
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS bug_reports_update ON bug_reports;
|
||||||
|
CREATE POLICY bug_reports_update ON bug_reports
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Audit trigger for bug report actions
|
||||||
|
CREATE OR REPLACE FUNCTION audit_bug_report_action()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
|
||||||
|
VALUES (
|
||||||
|
'bug_reports',
|
||||||
|
NEW.id,
|
||||||
|
'BUG_CREATED',
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', NEW.title,
|
||||||
|
'severity', NEW.severity,
|
||||||
|
'page_url', NEW.page_url,
|
||||||
|
'status', NEW.status
|
||||||
|
),
|
||||||
|
NEW.reported_by
|
||||||
|
);
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
|
||||||
|
VALUES (
|
||||||
|
'bug_reports',
|
||||||
|
NEW.id,
|
||||||
|
CASE NEW.status
|
||||||
|
WHEN 'resolved' THEN 'BUG_RESOLVED'
|
||||||
|
ELSE 'BUG_UPDATED'
|
||||||
|
END,
|
||||||
|
jsonb_build_object(
|
||||||
|
'old_status', OLD.status,
|
||||||
|
'new_status', NEW.status,
|
||||||
|
'assigned_to', NEW.assigned_to,
|
||||||
|
'resolution_notes', NEW.resolution_notes
|
||||||
|
),
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF OLD.assigned_to IS DISTINCT FROM NEW.assigned_to AND NEW.assigned_to IS NOT NULL THEN
|
||||||
|
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
|
||||||
|
VALUES (
|
||||||
|
'bug_reports',
|
||||||
|
NEW.id,
|
||||||
|
'BUG_ASSIGNED',
|
||||||
|
jsonb_build_object(
|
||||||
|
'assigned_to', NEW.assigned_to,
|
||||||
|
'previous_assignee', OLD.assigned_to,
|
||||||
|
'status', NEW.status
|
||||||
|
),
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_bug_report ON bug_reports;
|
||||||
|
CREATE TRIGGER trg_audit_bug_report
|
||||||
|
AFTER INSERT OR UPDATE ON bug_reports
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_bug_report_action();
|
||||||
|
|
||||||
|
-- Widen audit action constraint to include bug report events
|
||||||
|
ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_action;
|
||||||
|
ALTER TABLE audit_logs ADD CONSTRAINT chk_audit_action
|
||||||
|
CHECK (action::text = ANY (ARRAY[
|
||||||
|
'CREATE', 'UPDATE', 'DELETE',
|
||||||
|
'BUG_CREATED', 'BUG_UPDATED', 'BUG_ASSIGNED', 'BUG_RESOLVED',
|
||||||
|
'LOGIN', 'LOGOUT'
|
||||||
|
]::text[]));
|
||||||
|
|
||||||
|
-- Prevent SALES_USER and DEVELOPER from reading bug_reports directly
|
||||||
|
-- via RLS bypass attempts (additional safety via trigger)
|
||||||
|
CREATE OR REPLACE FUNCTION prevent_bug_report_read_bypass()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
user_level INT;
|
||||||
|
BEGIN
|
||||||
|
user_level := current_user_hierarchy_level();
|
||||||
|
IF user_level IS NULL OR user_level > 2 THEN
|
||||||
|
RAISE EXCEPTION 'ACCESS DENIED: Only ADMIN and SUPER_ADMIN can view bug reports.';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
@@ -34,5 +34,11 @@ BEGIN;
|
|||||||
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
||||||
\i 009_settings.sql
|
\i 009_settings.sql
|
||||||
|
|
||||||
|
\echo '=== Running 013_security_upgrade.sql (Security Architecture: RLS, Encryption, Master Keys, Backups) ==='
|
||||||
|
\i 013_security_upgrade.sql
|
||||||
|
|
||||||
|
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
|
||||||
|
\i 014_bug_reports.sql
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+25
File diff suppressed because one or more lines are too long
@@ -1,4 +1,12 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
// In development, generate a random JWT secret on every server start.
|
||||||
|
// This invalidates all previously issued JWT tokens, ensuring the user
|
||||||
|
// must re-authenticate after each dev server restart.
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
process.env.JWT_SECRET = crypto.randomBytes(32).toString("hex")
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"playwright": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"playwright-mcp.cmd",
|
||||||
|
"--browser",
|
||||||
|
"chrome",
|
||||||
|
"--user-data-dir",
|
||||||
|
"C:\\Users\\Caitlin\\AppData\\Local\\Google\\Chrome\\User Data\\Default",
|
||||||
|
],
|
||||||
|
"enabled": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
Generated
+586
-281
File diff suppressed because it is too large
Load Diff
+9
-3
@@ -4,12 +4,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||||
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
|
"dev:signaling": "node signaling-server.mjs",
|
||||||
|
"dev:open": "powershell -NoProfile -Command \"Start-Sleep 8; Start-Process 'http://localhost:3001/splash'\"",
|
||||||
|
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||||
"dev:next": "next dev -p 3006",
|
"dev:next": "next dev -p 3006",
|
||||||
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
||||||
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
||||||
"dev:rust": "node ai-server/index.mjs",
|
"dev:rust": "node ai-server/index.mjs",
|
||||||
"dev:browser-use": "cd browser-use-service && python main.py",
|
"dev:browser-use": "set FX_PROFILE=C:\\Users\\USER-PC\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\h8p11vlj.default-release && cd browser-use-service && python main.py",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -32,6 +34,8 @@
|
|||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@supabase/ssr": "^0.12.0",
|
||||||
|
"@supabase/supabase-js": "^2.108.2",
|
||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -47,6 +51,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
|||||||
+1
-1
@@ -446,7 +446,7 @@ async fn main() {
|
|||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||||
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
|
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||||
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
param(
|
||||||
|
[string]$BackupDir = "$PSScriptRoot\..\backups",
|
||||||
|
[int]$RetentionDays = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$startTime = Get-Date
|
||||||
|
|
||||||
|
# Ensure backup directory exists
|
||||||
|
if (-not (Test-Path $BackupDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine database URL from env or .env.local
|
||||||
|
$dbUrl = $env:DATABASE_URL
|
||||||
|
if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) {
|
||||||
|
$envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw
|
||||||
|
if ($envContent -match 'DATABASE_URL=(.+)') {
|
||||||
|
$dbUrl = $Matches[1].Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $dbUrl) {
|
||||||
|
Write-Error "DATABASE_URL not found. Set it as an environment variable or in .env.local"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse the database URL
|
||||||
|
$uri = [System.Uri]$dbUrl
|
||||||
|
$pgUser = $uri.UserInfo.Split(':')[0]
|
||||||
|
$pgPass = $uri.UserInfo.Split(':')[1]
|
||||||
|
$pgHost = $uri.Host
|
||||||
|
$pgPort = $uri.Port
|
||||||
|
$pgDb = $uri.AbsolutePath.TrimStart('/')
|
||||||
|
|
||||||
|
# Set PGPASSWORD for pg_dump
|
||||||
|
$env:PGPASSWORD = $pgPass
|
||||||
|
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||||
|
$filename = "crm_backup_$timestamp.sql"
|
||||||
|
$filepath = Join-Path $BackupDir $filename
|
||||||
|
|
||||||
|
Write-Output "Starting backup of database '$pgDb' to $filepath"
|
||||||
|
Write-Output "Database: $pgHost:$pgPort/$pgDb"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Run pg_dump
|
||||||
|
$pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) {
|
||||||
|
"pg_dump"
|
||||||
|
} else {
|
||||||
|
"$env:TEMP\pg\pgsql\bin\pg_dump.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --verbose --file $filename 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
if ($exitCode -ne 0 -or -not (Test-Path $filename)) {
|
||||||
|
throw "pg_dump failed with exit code $exitCode"
|
||||||
|
}
|
||||||
|
|
||||||
|
Move-Item -Path $filename -Destination $filepath -Force
|
||||||
|
$fileInfo = Get-Item $filepath
|
||||||
|
$fileSize = $fileInfo.Length
|
||||||
|
|
||||||
|
# Verify backup by checking if file is valid custom format
|
||||||
|
$verifyOutput = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --schema-only --file "$filename.verify" 2>&1
|
||||||
|
$verifyExitCode = $LASTEXITCODE
|
||||||
|
if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force }
|
||||||
|
|
||||||
|
$verificationStatus = if ($verifyExitCode -eq 0) { "verified" } else { "failed" }
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
$endTime = Get-Date
|
||||||
|
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
|
||||||
|
|
||||||
|
# Log the backup
|
||||||
|
$logQuery = @"
|
||||||
|
INSERT INTO backup_logs (backup_type, status, file_name, file_size_bytes, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days)
|
||||||
|
VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationStatus', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays);
|
||||||
|
"@
|
||||||
|
|
||||||
|
# Try to log to database (may fail if db is not reachable for logging, that's OK)
|
||||||
|
try {
|
||||||
|
$env:PGPASSWORD = $pgPass
|
||||||
|
& $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $logQuery 2>&1 | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Could not log backup to database: $_"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Backup completed successfully:"
|
||||||
|
Write-Output " File: $filename"
|
||||||
|
Write-Output " Size: $([math]::Round($fileSize / 1MB, 2)) MB"
|
||||||
|
Write-Output " Duration: ${durationSeconds}s"
|
||||||
|
Write-Output " Status: $verificationStatus"
|
||||||
|
|
||||||
|
# Cleanup old backups (beyond retention period)
|
||||||
|
$cutoff = (Get-Date).AddDays(-$RetentionDays)
|
||||||
|
$oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object {
|
||||||
|
$_.CreationTime -lt $cutoff
|
||||||
|
}
|
||||||
|
foreach ($old in $oldBackups) {
|
||||||
|
Remove-Item $old.FullName -Force
|
||||||
|
Write-Output "Removed old backup: $($old.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Error "Backup failed: $_"
|
||||||
|
|
||||||
|
# Log failure
|
||||||
|
$endTime = Get-Date
|
||||||
|
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
|
||||||
|
$errorMsg = $_.ToString().Replace("'", "''")
|
||||||
|
$failQuery = @"
|
||||||
|
INSERT INTO backup_logs (backup_type, status, file_name, error_message, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days)
|
||||||
|
VALUES ('full', 'failed', '$filename', '$errorMsg', $exitCode, 'failed', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays);
|
||||||
|
"@
|
||||||
|
try {
|
||||||
|
$env:PGPASSWORD = $pgPass
|
||||||
|
& $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $failQuery 2>&1 | Out-Null
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { createServer } from "http"
|
||||||
|
import { Server } from "socket.io"
|
||||||
|
import { SignJWT, jwtVerify } from "jose"
|
||||||
|
import pg from "pg"
|
||||||
|
|
||||||
|
const PORT = process.env.SIGNALING_PORT || 3007
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
||||||
|
|
||||||
|
const httpServer = createServer()
|
||||||
|
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
|
||||||
|
|
||||||
|
const onlineUsers = new Map()
|
||||||
|
const rooms = new Map()
|
||||||
|
|
||||||
|
io.use((socket, next) => {
|
||||||
|
const token = socket.handshake.auth?.token
|
||||||
|
if (token) {
|
||||||
|
jwtVerify(token, JWT_SECRET)
|
||||||
|
.then(({ payload }) => {
|
||||||
|
socket.data.userId = payload.userId
|
||||||
|
socket.data.role = payload.role
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
socket.data.userId = null
|
||||||
|
socket.data.role = null
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
socket.data.userId = null
|
||||||
|
socket.data.role = null
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
const userId = socket.data.userId
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
onlineUsers.set(userId, socket.id)
|
||||||
|
socket.broadcast.emit("user:online", { userId })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
socket.on("user:lookup", async ({ phone }, callback) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, username, first_name, last_name, phone, avatar_url
|
||||||
|
FROM users
|
||||||
|
WHERE phone = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[phone],
|
||||||
|
)
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const user = result.rows[0]
|
||||||
|
callback({
|
||||||
|
found: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
phone: user.phone,
|
||||||
|
avatar: user.avatar_url,
|
||||||
|
online: onlineUsers.has(user.id),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
callback({ found: false })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
callback({ found: false, error: "Lookup failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
|
||||||
|
const targetSocketId = onlineUsers.get(to)
|
||||||
|
if (targetSocketId) {
|
||||||
|
let info = callerInfo
|
||||||
|
if (!info) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, username, first_name, last_name, avatar_url
|
||||||
|
FROM users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
)
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const u = result.rows[0]
|
||||||
|
info = {
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
firstName: u.first_name,
|
||||||
|
lastName: u.last_name,
|
||||||
|
avatar: u.avatar_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
io.to(targetSocketId).emit("call:incoming", {
|
||||||
|
from: userId,
|
||||||
|
sdp,
|
||||||
|
callerInfo: info,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
socket.emit("call:user-offline", { userId: to })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:answer", ({ to, sdp }) => {
|
||||||
|
const targetSocketId = onlineUsers.get(to)
|
||||||
|
if (targetSocketId) {
|
||||||
|
io.to(targetSocketId).emit("call:answered", { from: userId, sdp })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:ice-candidate", ({ to, candidate }) => {
|
||||||
|
const targetSocketId = onlineUsers.get(to)
|
||||||
|
if (targetSocketId) {
|
||||||
|
io.to(targetSocketId).emit("call:ice-candidate", { from: userId, candidate })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:end", ({ to }) => {
|
||||||
|
const targetSocketId = onlineUsers.get(to)
|
||||||
|
if (targetSocketId) {
|
||||||
|
io.to(targetSocketId).emit("call:ended", { from: userId })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:reject", ({ to }) => {
|
||||||
|
const targetSocketId = onlineUsers.get(to)
|
||||||
|
if (targetSocketId) {
|
||||||
|
io.to(targetSocketId).emit("call:rejected", { from: userId })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:busy", ({ to }) => {
|
||||||
|
const targetSocketId = onlineUsers.get(to)
|
||||||
|
if (targetSocketId) {
|
||||||
|
io.to(targetSocketId).emit("call:busy", { from: userId })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on("room:join", ({ roomId, displayName }) => {
|
||||||
|
const participantId = userId || `anon-${socket.id}`
|
||||||
|
const participantLabel = displayName || participantId
|
||||||
|
|
||||||
|
if (!rooms.has(roomId)) {
|
||||||
|
rooms.set(roomId, [])
|
||||||
|
}
|
||||||
|
const participants = rooms.get(roomId)
|
||||||
|
participants.push({ socketId: socket.id, id: participantId, label: participantLabel })
|
||||||
|
socket.join(roomId)
|
||||||
|
|
||||||
|
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
||||||
|
|
||||||
|
if (participants.length === 1) {
|
||||||
|
socket.emit("room:waiting", { roomId, participants: list })
|
||||||
|
} else if (participants.length === 2) {
|
||||||
|
io.to(participants[0].socketId).emit("room:initiate-offer", { roomId, participants: list })
|
||||||
|
io.to(participants[1].socketId).emit("room:participant-joined", { roomId, participants: list })
|
||||||
|
} else {
|
||||||
|
socket.emit("room:participant-joined", { roomId, participants: list })
|
||||||
|
socket.to(roomId).emit("room:participant-joined", { roomId, participants: list })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:offer", ({ roomId, sdp }) => {
|
||||||
|
socket.to(roomId).emit("room:offer", { sdp })
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:answer", ({ roomId, sdp }) => {
|
||||||
|
socket.to(roomId).emit("room:answer", { sdp })
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
|
||||||
|
socket.to(roomId).emit("room:ice-candidate", { candidate })
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:leave", ({ roomId }) => {
|
||||||
|
socket.leave(roomId)
|
||||||
|
const participants = rooms.get(roomId)
|
||||||
|
if (participants) {
|
||||||
|
const idx = participants.findIndex(p => p.socketId === socket.id)
|
||||||
|
if (idx !== -1) participants.splice(idx, 1)
|
||||||
|
if (participants.length === 0) {
|
||||||
|
rooms.delete(roomId)
|
||||||
|
} else {
|
||||||
|
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
||||||
|
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
for (const [roomId, participants] of rooms) {
|
||||||
|
const idx = participants.findIndex(p => p.socketId === socket.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
participants.splice(idx, 1)
|
||||||
|
if (participants.length === 0) {
|
||||||
|
rooms.delete(roomId)
|
||||||
|
} else {
|
||||||
|
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
||||||
|
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (userId) {
|
||||||
|
onlineUsers.delete(userId)
|
||||||
|
socket.broadcast.emit("user:offline", { userId })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`[signaling] server running on port ${PORT}`)
|
||||||
|
})
|
||||||
+453
@@ -0,0 +1,453 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Loading CoastIT CRM</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #0a0a1a;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Robot */
|
||||||
|
.robot {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 160px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
animation: float 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-12px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-head {
|
||||||
|
width: 70px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border-radius: 16px 16px 8px 8px;
|
||||||
|
margin: 0 auto 4px;
|
||||||
|
position: relative;
|
||||||
|
animation: bob 0.6s ease-in-out infinite alternate;
|
||||||
|
box-shadow: 0 0 30px rgba(99,102,241,0.3);
|
||||||
|
}
|
||||||
|
@keyframes bob {
|
||||||
|
0% { transform: rotate(-3deg); }
|
||||||
|
100% { transform: rotate(3deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-eye {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #22d3ee;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 20px;
|
||||||
|
box-shadow: 0 0 8px #22d3ee;
|
||||||
|
animation: blink 3s infinite;
|
||||||
|
}
|
||||||
|
.robot-eye.left { left: 16px; }
|
||||||
|
.robot-eye.right { right: 16px; }
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 94%, 100% { transform: scaleY(1); }
|
||||||
|
97% { transform: scaleY(0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-antenna {
|
||||||
|
width: 4px;
|
||||||
|
height: 12px;
|
||||||
|
background: #8b5cf6;
|
||||||
|
margin: -10px auto 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
animation: antenna-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.robot-antenna::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #22d3ee;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: -2px auto 0;
|
||||||
|
box-shadow: 0 0 10px #22d3ee;
|
||||||
|
}
|
||||||
|
@keyframes antenna-pulse {
|
||||||
|
0%, 100% { opacity: 0.7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-body {
|
||||||
|
width: 60px;
|
||||||
|
height: 50px;
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 20px rgba(99,102,241,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-arm {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 36px;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 6px;
|
||||||
|
top: 6px;
|
||||||
|
animation: swing 0.8s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.robot-arm.left { left: -16px; transform-origin: top center; }
|
||||||
|
.robot-arm.right { right: -16px; transform-origin: top center; animation-delay: 0.4s; }
|
||||||
|
@keyframes swing {
|
||||||
|
0% { transform: rotate(-25deg); }
|
||||||
|
100% { transform: rotate(25deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-leg {
|
||||||
|
position: absolute;
|
||||||
|
width: 14px;
|
||||||
|
height: 30px;
|
||||||
|
background: #4f46e5;
|
||||||
|
border-radius: 4px;
|
||||||
|
bottom: -28px;
|
||||||
|
animation: step 0.6s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.robot-leg.left { left: 8px; }
|
||||||
|
.robot-leg.right { right: 8px; animation-delay: 0.3s; }
|
||||||
|
@keyframes step {
|
||||||
|
0% { transform: translateY(0) rotate(-8deg); }
|
||||||
|
100% { transform: translateY(-4px) rotate(8deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.services {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
.service {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.service.ready {
|
||||||
|
border-color: rgba(34,211,238,0.3);
|
||||||
|
background: rgba(34,211,238,0.06);
|
||||||
|
}
|
||||||
|
.service.failed {
|
||||||
|
border-color: rgba(239,68,68,0.3);
|
||||||
|
background: rgba(239,68,68,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.service.ready .service-name {
|
||||||
|
opacity: 1;
|
||||||
|
color: #22d3ee;
|
||||||
|
}
|
||||||
|
.service.failed .service-name {
|
||||||
|
opacity: 1;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
transition: all 0.4s;
|
||||||
|
}
|
||||||
|
.service.ready .status-dot {
|
||||||
|
background: #22d3ee;
|
||||||
|
box-shadow: 0 0 6px rgba(34,211,238,0.5);
|
||||||
|
}
|
||||||
|
.service.failed .status-dot {
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 6px rgba(239,68,68,0.5);
|
||||||
|
}
|
||||||
|
.status-dot:nth-child(2) { transition-delay: 0.1s; }
|
||||||
|
.status-dot:nth-child(3) { transition-delay: 0.2s; }
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.4;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.service.ready .status-text {
|
||||||
|
opacity: 0.8;
|
||||||
|
color: #22d3ee;
|
||||||
|
}
|
||||||
|
.service.failed .status-text {
|
||||||
|
opacity: 0.8;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading text */
|
||||||
|
.loading-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #22d3ee);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
animation: pulse-text 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse-text {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Launch gear (inline, below everything) */
|
||||||
|
.launch-overlay {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.launch-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.launch-gear {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border: 4px solid #6366f1;
|
||||||
|
border-top-color: #22d3ee;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.launch-text {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #22d3ee;
|
||||||
|
animation: scale-in 0.5s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes scale-in {
|
||||||
|
0% { transform: scale(0.5); opacity: 0; }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error state */
|
||||||
|
.loading-text.error {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
display: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.error-msg.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
display: none;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.retry-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.retry-btn.active {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Robot -->
|
||||||
|
<div class="robot">
|
||||||
|
<div class="robot-antenna"></div>
|
||||||
|
<div class="robot-head">
|
||||||
|
<div class="robot-eye left"></div>
|
||||||
|
<div class="robot-eye right"></div>
|
||||||
|
</div>
|
||||||
|
<div class="robot-body">
|
||||||
|
<div class="robot-arm left"></div>
|
||||||
|
<div class="robot-arm right"></div>
|
||||||
|
<div class="robot-leg left"></div>
|
||||||
|
<div class="robot-leg right"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
<div class="services">
|
||||||
|
<div class="service" id="svc-ai">
|
||||||
|
<span class="service-name">AI Server</span>
|
||||||
|
<div class="status-dots">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-text" id="status-ai">waiting...</span>
|
||||||
|
</div>
|
||||||
|
<div class="service" id="svc-scraper">
|
||||||
|
<span class="service-name">Scraper</span>
|
||||||
|
<div class="status-dots">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-text" id="status-scraper">waiting...</span>
|
||||||
|
</div>
|
||||||
|
<div class="service" id="svc-frontend">
|
||||||
|
<span class="service-name">Frontend</span>
|
||||||
|
<div class="status-dots">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-text" id="status-frontend">waiting...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading-text" id="loading-text">Loading...</div>
|
||||||
|
|
||||||
|
<!-- Launch gear (inline, below everything) -->
|
||||||
|
<div class="launch-overlay" id="launch-overlay">
|
||||||
|
<div class="launch-gear"></div>
|
||||||
|
<div class="launch-text">🚀 Launching gear!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-msg" id="error-msg"></div>
|
||||||
|
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MAX_ATTEMPTS = 30;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
const CHECKS = [
|
||||||
|
{ id: 'ai', key: 'ai', ready: false, failed: false },
|
||||||
|
{ id: 'scraper', key: 'scraper', ready: false, failed: false },
|
||||||
|
{ id: 'frontend',key: 'frontend', ready: false, failed: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
|
||||||
|
const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' };
|
||||||
|
|
||||||
|
async function checkAllServices() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/status', { cache: 'no-store' });
|
||||||
|
const data = await res.json();
|
||||||
|
for (const svc of CHECKS) {
|
||||||
|
svc.ready = data[svc.key] === true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const svc of CHECKS) svc.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
let allReady = true;
|
||||||
|
let anyFailed = false;
|
||||||
|
const failedNames = [];
|
||||||
|
|
||||||
|
for (const svc of CHECKS) {
|
||||||
|
const el = document.getElementById('svc-' + svc.id);
|
||||||
|
const statusText = document.getElementById('status-' + svc.id);
|
||||||
|
el.classList.remove('ready', 'failed');
|
||||||
|
|
||||||
|
if (svc.ready) {
|
||||||
|
el.classList.add('ready');
|
||||||
|
statusText.textContent = MSGS[svc.id];
|
||||||
|
} else if (svc.failed) {
|
||||||
|
el.classList.add('failed');
|
||||||
|
statusText.textContent = 'Failed';
|
||||||
|
anyFailed = true;
|
||||||
|
allReady = false;
|
||||||
|
failedNames.push(NAMES[svc.id]);
|
||||||
|
} else {
|
||||||
|
statusText.textContent = 'waiting...';
|
||||||
|
allReady = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingText = document.getElementById('loading-text');
|
||||||
|
const errorMsg = document.getElementById('error-msg');
|
||||||
|
const retryBtn = document.getElementById('retry-btn');
|
||||||
|
|
||||||
|
if (anyFailed) {
|
||||||
|
loadingText.className = 'loading-text error';
|
||||||
|
loadingText.textContent = 'Something went wrong';
|
||||||
|
errorMsg.textContent = failedNames.join(', ') + ' failed to start. Check your terminal for details.';
|
||||||
|
errorMsg.classList.add('active');
|
||||||
|
retryBtn.classList.add('active');
|
||||||
|
document.getElementById('launch-overlay').classList.remove('active');
|
||||||
|
} else if (allReady) {
|
||||||
|
loadingText.className = 'loading-text';
|
||||||
|
loadingText.textContent = 'All systems ready!';
|
||||||
|
errorMsg.classList.remove('active');
|
||||||
|
retryBtn.classList.remove('active');
|
||||||
|
document.getElementById('launch-overlay').classList.add('active');
|
||||||
|
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
|
||||||
|
} else {
|
||||||
|
loadingText.className = 'loading-text';
|
||||||
|
const ready = CHECKS.filter(s => s.ready).length;
|
||||||
|
loadingText.textContent = `Loading... (${ready}/${CHECKS.length})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
attempts++;
|
||||||
|
if (attempts > MAX_ATTEMPTS) {
|
||||||
|
for (const svc of CHECKS) {
|
||||||
|
if (!svc.ready) svc.failed = true;
|
||||||
|
}
|
||||||
|
updateUI();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkAllServices();
|
||||||
|
updateUI();
|
||||||
|
|
||||||
|
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed)) {
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
Search, Send, Phone, MoreHorizontal, Paperclip,
|
||||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||||
CornerDownRight, Forward, Pencil, Download,
|
CornerDownRight, Forward, Pencil, Download,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -28,6 +28,7 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
||||||
import data from "@emoji-mart/data"
|
import data from "@emoji-mart/data"
|
||||||
import Picker from "@emoji-mart/react"
|
import Picker from "@emoji-mart/react"
|
||||||
|
|
||||||
@@ -124,6 +125,7 @@ export default function ChatsPage() {
|
|||||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||||
|
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
|
||||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
@@ -642,12 +644,9 @@ export default function ChatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
|
|
||||||
<Video className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
@@ -995,6 +994,8 @@ export default function ChatsPage() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function DashboardPage() {
|
|||||||
<div className="space-y-6 relative">
|
<div className="space-y-6 relative">
|
||||||
{/* Daily Bugle watermark */}
|
{/* Daily Bugle watermark */}
|
||||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
|
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
|
||||||
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#e62020] dark:text-[#FF6666] tracking-wider">
|
||||||
DAILY BUGLE
|
DAILY BUGLE
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { chatWithAI } from "@/lib/ai"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
// This route handler has a known issue with Next.js 15 fetch augmentation
|
|
||||||
// on this platform. The client-side code calls the AI server directly
|
|
||||||
// via browser fetch (CORS is open). This handler returns a fallback.
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({ error: "AI service unavailable on server. Use client-side mode." }, { status: 503 })
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = await request.json()
|
||||||
|
if (!message || typeof message !== "string") {
|
||||||
|
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCookie = request.cookies.get("session")
|
||||||
|
const jwtToken = sessionCookie?.value
|
||||||
|
if (!jwtToken) {
|
||||||
|
return NextResponse.json({ error: "No session token" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await chatWithAI(message, jwtToken)
|
||||||
|
return NextResponse.json({ response })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("AI chat error:", error)
|
||||||
|
return NextResponse.json({ error: error.message || "AI service error" }, { status: 500 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
resetFailedAttempts,
|
resetFailedAttempts,
|
||||||
isAccountLocked,
|
isAccountLocked,
|
||||||
createSession,
|
createSession,
|
||||||
|
setSessionContext,
|
||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -106,6 +107,7 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await createSession(dbUser.id, dbUser.role_name)
|
await createSession(dbUser.id, dbUser.role_name)
|
||||||
|
await setSessionContext(dbUser.id, ipAddress)
|
||||||
|
|
||||||
const user = mapDbUserToSessionUser(dbUser)
|
const user = mapDbUserToSessionUser(dbUser)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import {
|
||||||
|
getSessionUser,
|
||||||
|
decryptPassword,
|
||||||
|
setSessionContext,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await request.json()
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "userId is required." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 result = await query(
|
||||||
|
`SELECT id, username, email, first_name, last_name, password_encrypted
|
||||||
|
FROM users WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = result.rows[0]
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found." }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password_encrypted) {
|
||||||
|
return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintextPassword = await decryptPassword(user.password_encrypted)
|
||||||
|
if (!plaintextPassword) {
|
||||||
|
return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: `${user.first_name} ${user.last_name}`,
|
||||||
|
},
|
||||||
|
password: plaintextPassword,
|
||||||
|
}, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Password recovery error:", error)
|
||||||
|
return NextResponse.json({ error: "Recovery service unavailable." }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden. Only admins can update bug reports." }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await routeParams
|
||||||
|
const { status, assigned_to, resolution_notes } = await request.json()
|
||||||
|
|
||||||
|
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 validStatuses = ["open", "in_progress", "resolved", "closed"]
|
||||||
|
if (status && !validStatuses.includes(status)) {
|
||||||
|
return NextResponse.json({ error: "Invalid status." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id])
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Bug report not found." }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = []
|
||||||
|
const values: unknown[] = []
|
||||||
|
let paramIndex = 1
|
||||||
|
|
||||||
|
if (status !== undefined) {
|
||||||
|
updates.push(`status = $${paramIndex++}`)
|
||||||
|
values.push(status)
|
||||||
|
}
|
||||||
|
if (assigned_to !== undefined) {
|
||||||
|
updates.push(`assigned_to = $${paramIndex++}`)
|
||||||
|
values.push(assigned_to === "null" ? null : assigned_to)
|
||||||
|
}
|
||||||
|
if (resolution_notes !== undefined) {
|
||||||
|
updates.push(`resolution_notes = $${paramIndex++}`)
|
||||||
|
values.push(resolution_notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No fields to update." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`)
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE bug_reports SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating bug report:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to update bug report." }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, severity, page_url, screenshot_url } = await request.json()
|
||||||
|
|
||||||
|
if (!title || !description) {
|
||||||
|
return NextResponse.json({ error: "Title and description are required." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validSeverities = ["low", "medium", "high", "critical"]
|
||||||
|
if (severity && !validSeverities.includes(severity)) {
|
||||||
|
return NextResponse.json({ error: "Invalid severity." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO bug_reports (reported_by, title, description, severity, page_url, screenshot_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
[sessionUser.id, title, description, severity || "medium", page_url || null, screenshot_url || null]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
id: result.rows[0].id,
|
||||||
|
created_at: result.rows[0].created_at,
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating bug report:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to submit bug report." }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden. Only admins can view bug reports." }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const status = searchParams.get("status")
|
||||||
|
const severity = searchParams.get("severity")
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|
||||||
|
let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url,
|
||||||
|
br.screenshot_url, br.status, br.resolution_notes,
|
||||||
|
br.created_at, br.updated_at,
|
||||||
|
reporter.id AS reporter_id,
|
||||||
|
reporter.first_name AS reporter_first_name,
|
||||||
|
reporter.last_name AS reporter_last_name,
|
||||||
|
reporter.email AS reporter_email,
|
||||||
|
assignee.id AS assignee_id,
|
||||||
|
assignee.first_name AS assignee_first_name,
|
||||||
|
assignee.last_name AS assignee_last_name
|
||||||
|
FROM bug_reports br
|
||||||
|
JOIN users reporter ON reporter.id = br.reported_by
|
||||||
|
LEFT JOIN users assignee ON assignee.id = br.assigned_to`
|
||||||
|
const params: unknown[] = []
|
||||||
|
const conditions: string[] = []
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
conditions.push(`br.status = $${params.length + 1}`)
|
||||||
|
params.push(status)
|
||||||
|
}
|
||||||
|
if (severity) {
|
||||||
|
conditions.push(`br.severity = $${params.length + 1}`)
|
||||||
|
params.push(severity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
sql += " WHERE " + conditions.join(" AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += " ORDER BY br.created_at DESC LIMIT $" + (params.length + 1) + " OFFSET $" + (params.length + 2)
|
||||||
|
params.push(limit, offset)
|
||||||
|
|
||||||
|
const result = await query(sql, params)
|
||||||
|
|
||||||
|
const reports = result.rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
severity: row.severity,
|
||||||
|
page_url: row.page_url,
|
||||||
|
screenshot_url: row.screenshot_url,
|
||||||
|
status: row.status,
|
||||||
|
resolution_notes: row.resolution_notes,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
reporter: {
|
||||||
|
id: row.reporter_id,
|
||||||
|
name: `${row.reporter_first_name} ${row.reporter_last_name}`,
|
||||||
|
email: row.reporter_email,
|
||||||
|
},
|
||||||
|
assignee: row.assignee_id ? {
|
||||||
|
id: row.assignee_id,
|
||||||
|
name: `${row.assignee_first_name} ${row.assignee_last_name}`,
|
||||||
|
} : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ reports }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bug reports:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to fetch bug reports." }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export async function GET() {
|
|||||||
u.id AS other_user_id,
|
u.id AS other_user_id,
|
||||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||||
u.email AS other_user_email,
|
u.email AS other_user_email,
|
||||||
|
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 ORDER BY created_at DESC LIMIT 1) AS last_message,
|
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||||
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||||
@@ -39,6 +40,7 @@ export async function GET() {
|
|||||||
id: row.other_user_id,
|
id: row.other_user_id,
|
||||||
name: row.other_user_name,
|
name: row.other_user_name,
|
||||||
email: row.other_user_email,
|
email: row.other_user_email,
|
||||||
|
phone: row.other_user_phone || "",
|
||||||
avatar: avatarSvgUrl(row.other_user_name),
|
avatar: avatarSvgUrl(row.other_user_name),
|
||||||
},
|
},
|
||||||
lastMessage: row.last_message || "",
|
lastMessage: row.last_message || "",
|
||||||
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const otherUser = await query(
|
const otherUser = await query(
|
||||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
`SELECT id, first_name || ' ' || last_name AS name, email, phone, avatar_url
|
||||||
FROM users WHERE id = $1`,
|
FROM users WHERE id = $1`,
|
||||||
[userId],
|
[userId],
|
||||||
)
|
)
|
||||||
@@ -105,6 +107,7 @@ export async function POST(request: NextRequest) {
|
|||||||
id: other.id,
|
id: other.id,
|
||||||
name: other.name,
|
name: other.name,
|
||||||
email: other.email,
|
email: other.email,
|
||||||
|
phone: other.phone || "",
|
||||||
avatar: avatarSvgUrl(other.name),
|
avatar: avatarSvgUrl(other.name),
|
||||||
},
|
},
|
||||||
lastMessage: "",
|
lastMessage: "",
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { phone, token: clientToken } = await request.json()
|
||||||
|
if (!phone) {
|
||||||
|
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = clientToken || crypto.randomBytes(24).toString("hex")
|
||||||
|
await query(
|
||||||
|
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||||
|
[token, phone],
|
||||||
|
)
|
||||||
|
|
||||||
|
const origin = request.headers.get("origin") || "http://localhost:3000"
|
||||||
|
const inviteUrl = `${origin}/join/${token}`
|
||||||
|
|
||||||
|
return NextResponse.json({ token, inviteUrl })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`,
|
||||||
|
[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
const websiteTheme = result.rows[0]?.website_theme || "spidey"
|
||||||
|
return NextResponse.json({ websiteTheme })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Website theme GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const theme = body.websiteTheme || "spidey"
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||||
|
[user.id, JSON.stringify({ website_theme: theme })],
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, websiteTheme: theme })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Website theme PUT error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const phone = request.nextUrl.searchParams.get("phone")
|
||||||
|
if (!phone) {
|
||||||
|
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT id, username, first_name, last_name, phone, avatar_url
|
||||||
|
FROM users
|
||||||
|
WHERE phone = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[phone],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ found: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.rows[0]
|
||||||
|
return NextResponse.json({
|
||||||
|
found: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
phone: user.phone,
|
||||||
|
avatar: user.avatar_url,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Lookup failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { hashPassword, getSessionUser } from "@/lib/auth"
|
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -63,17 +63,20 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await setSessionContext(sessionUser.id)
|
||||||
|
|
||||||
const nameParts = name.trim().split(/\s+/)
|
const nameParts = name.trim().split(/\s+/)
|
||||||
const firstName = nameParts[0]
|
const firstName = nameParts[0]
|
||||||
const lastName = nameParts.slice(1).join(" ") || firstName
|
const lastName = nameParts.slice(1).join(" ") || firstName
|
||||||
const username = email.split("@")[0]
|
const username = email.split("@")[0]
|
||||||
const passwordHash = await hashPassword(password)
|
const passwordHash = await hashPassword(password)
|
||||||
|
const passwordEncrypted = await encryptPassword(password)
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
|
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
|
[username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id]
|
||||||
)
|
)
|
||||||
|
|
||||||
const roleId = (
|
const roleId = (
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, use, useCallback } from "react"
|
||||||
|
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
|
||||||
|
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
|
||||||
|
|
||||||
|
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
|
||||||
|
const { roomId } = use(params)
|
||||||
|
|
||||||
|
const {
|
||||||
|
callState,
|
||||||
|
isMuted,
|
||||||
|
isSpeakerOn,
|
||||||
|
callDuration,
|
||||||
|
error,
|
||||||
|
isReady,
|
||||||
|
participants,
|
||||||
|
joinRoom,
|
||||||
|
endCall,
|
||||||
|
toggleMute,
|
||||||
|
toggleSpeaker,
|
||||||
|
formatDuration,
|
||||||
|
} = useWebRTCCall({ anonymous: true })
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useState("")
|
||||||
|
const [joined, setJoined] = useState(false)
|
||||||
|
const [micError, setMicError] = useState<string | null>(null)
|
||||||
|
const [connectionTimeout, setConnectionTimeout] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady) return
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!isReady) setConnectionTimeout(true)
|
||||||
|
}, 10000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [isReady])
|
||||||
|
|
||||||
|
const handleJoin = useCallback(async () => {
|
||||||
|
if (!displayName.trim()) return
|
||||||
|
setMicError(null)
|
||||||
|
try {
|
||||||
|
await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
joinRoom(roomId, displayName.trim() || "Anonymous")
|
||||||
|
setJoined(true)
|
||||||
|
} catch {
|
||||||
|
setMicError("Microphone access is required to join the call. Please allow microphone access in your browser settings.")
|
||||||
|
}
|
||||||
|
}, [displayName, roomId, joinRoom])
|
||||||
|
|
||||||
|
const handleEnd = useCallback(() => {
|
||||||
|
endCall()
|
||||||
|
}, [endCall])
|
||||||
|
|
||||||
|
if (!joined) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||||
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||||
|
{connectionTimeout ? (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
|
||||||
|
</>
|
||||||
|
) : !isReady ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
|
||||||
|
<p className="text-[#888888] text-sm">Connecting to call...</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
|
||||||
|
{micError && (
|
||||||
|
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={!displayName.trim()}
|
||||||
|
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
Join Call
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||||
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||||
|
{callState === "calling" && (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
|
||||||
|
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||||
|
<PhoneOff className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callState === "waiting" && (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
|
||||||
|
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Participants ({participants.length})
|
||||||
|
</div>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||||
|
<PhoneOff className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callState === "participant_joined" && (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Participants ({participants.length})
|
||||||
|
</div>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||||
|
<PhoneOff className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callState === "connecting" && (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
|
||||||
|
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||||
|
<PhoneOff className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callState === "connected" && (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Participants ({participants.length})
|
||||||
|
</div>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-[#CC0000] font-mono text-lg mb-6">
|
||||||
|
{formatDuration(callDuration)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleSpeaker}
|
||||||
|
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||||
|
>
|
||||||
|
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||||
|
>
|
||||||
|
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEnd}
|
||||||
|
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||||
|
>
|
||||||
|
<PhoneOff className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(callState === "ended" || callState === "idle") && (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
|
||||||
|
{callDuration > 0 && (
|
||||||
|
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+65
-7
@@ -1,4 +1,5 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -29,15 +30,16 @@
|
|||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring: 221.2 83.2% 53.3%;
|
||||||
--sidebar: 222.2 84% 4.9%;
|
--sidebar: 216 12% 92%;
|
||||||
--sidebar-foreground: 210 40% 98%;
|
--sidebar-foreground: 0 0% 20%;
|
||||||
--sidebar-primary: 217.2 91.2% 59.8%;
|
--sidebar-primary: 0 91.2% 59.8%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
--sidebar-accent: 218 16% 87%;
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
--sidebar-accent-foreground: 0 0% 20%;
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
--sidebar-border: 220 11% 84%;
|
||||||
--sidebar-ring: 224.3 76.3% 48%;
|
--sidebar-ring: 0 76.3% 48%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
--theme-primary: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -574,6 +576,62 @@
|
|||||||
--sidebar-ring: 351 85% 52%;
|
--sidebar-ring: 351 85% 52%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bc-logo {
|
||||||
|
font-family: 'Raleway', sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: hsl(var(--sidebar-foreground));
|
||||||
|
padding: 8px 16px;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bc-logo .accent {
|
||||||
|
color: var(--theme-primary, #3b91f7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bc-logo::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border-top: 1.5px solid var(--theme-primary, #3b91f7);
|
||||||
|
border-left: 1.5px solid var(--theme-primary, #3b91f7);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
animation: drawTL 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bc-logo::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
border-bottom: 1.5px solid var(--theme-primary, #3b91f7);
|
||||||
|
border-right: 1.5px solid var(--theme-primary, #3b91f7);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
animation: drawBR 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drawTL {
|
||||||
|
0%, 5% { width: 0; height: 0; }
|
||||||
|
30% { width: 100%; height: 0; }
|
||||||
|
55% { width: 100%; height: 100%; }
|
||||||
|
85% { width: 100%; height: 100%; }
|
||||||
|
100% { width: 0; height: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drawBR {
|
||||||
|
0%, 5% { width: 0; height: 0; }
|
||||||
|
30% { width: 100%; height: 0; }
|
||||||
|
55% { width: 100%; height: 100%; }
|
||||||
|
85% { width: 100%; height: 100%; }
|
||||||
|
100% { width: 0; height: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ token: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorPage({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||||
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||||
|
{message}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function JoinPage({ params }: Props) {
|
||||||
|
const { token } = await params
|
||||||
|
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
if (UUID_REGEX.test(token)) {
|
||||||
|
redirect(`/call/${token}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
|
||||||
|
[token],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return <ErrorPage message="This call link is invalid." />
|
||||||
|
}
|
||||||
|
|
||||||
|
const invite = result.rows[0]
|
||||||
|
|
||||||
|
if (new Date(invite.expires_at) <= new Date()) {
|
||||||
|
return <ErrorPage message="This call has expired." />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||||
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||||
|
You're Invited!
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#888888] text-sm mb-6">
|
||||||
|
Someone wants to connect with you on our platform.
|
||||||
|
</p>
|
||||||
|
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
||||||
|
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
|
||||||
|
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#888888] mb-6">
|
||||||
|
Create an account to start making free voice calls to this person and others on the platform.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/register"
|
||||||
|
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next"
|
import type { Metadata, Viewport } from "next"
|
||||||
import { Inter } from "next/font/google"
|
import { Inter } from "next/font/google"
|
||||||
import { ThemeProvider } from "@/providers/theme-provider"
|
import { ThemeProvider } from "@/providers/theme-provider"
|
||||||
|
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
@@ -31,8 +32,10 @@ export default function RootLayout({
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} min-h-screen antialiased`}>
|
<body className={`${inter.variable} min-h-screen antialiased`}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
||||||
|
<WebsiteThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
</WebsiteThemeProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+9
-14
@@ -1,8 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { COMPANY_NAME } from "@/lib/constants"
|
|
||||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
const waves = [
|
const waves = [
|
||||||
@@ -14,7 +13,8 @@ const waves = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const searchParams = useSearchParams()
|
||||||
|
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
@@ -28,11 +28,11 @@ export default function LoginPage() {
|
|||||||
const testimonials = [
|
const testimonials = [
|
||||||
{
|
{
|
||||||
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
||||||
author: "Marcus Johnson, Sales Lead at Coast IT",
|
author: "Marcus Johnson, Sales Lead at Black Cipher",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
|
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
|
||||||
author: "Dillen van der Merwe, Madman of Coast IT",
|
author: "Dillen van der Merwe, Madman of Black Cipher",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ export default function LoginPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
router.push("/dashboard")
|
window.location.href = redirectTo
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
setError(data.error || "Invalid email or password.")
|
setError(data.error || "Invalid email or password.")
|
||||||
@@ -230,16 +230,11 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-[#0a0a0f]">
|
<div className="flex min-h-screen bg-[#0a0a0f]">
|
||||||
<div className="left-panel flex flex-1 flex-col p-12">
|
<div className="left-panel flex flex-1 flex-col p-12">
|
||||||
<div className="relative z-10">
|
|
||||||
<img
|
|
||||||
src="/logo/CompanyLogo.png"
|
|
||||||
alt={COMPANY_NAME}
|
|
||||||
className="h-44 w-auto object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
<div className="bc-logo mb-8" style={{"--theme-primary":"#1BB0CE"}}>
|
||||||
|
Black <span className="accent">Cipher</span>
|
||||||
|
</div>
|
||||||
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
||||||
Your Agency's{" "}
|
Your Agency's{" "}
|
||||||
<span className="growth-word">Growth</span>{" "}
|
<span className="growth-word">Growth</span>{" "}
|
||||||
|
|||||||
+110
-31
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, Fragment } from "react"
|
import { useState, useRef, useEffect, Fragment } from "react"
|
||||||
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
|
import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react"
|
||||||
|
|
||||||
function linkifyText(text: string) {
|
function linkifyText(text: string) {
|
||||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||||
@@ -24,11 +24,28 @@ export function AIChat() {
|
|||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
|
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const checkServer = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: "ping" }),
|
||||||
|
})
|
||||||
|
if (res.status !== 503) {
|
||||||
|
setBootState("ready")
|
||||||
|
} else {
|
||||||
|
setTimeout(checkServer, 2000)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTimeout(checkServer, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${AI_API}/ai/jobs`)
|
fetch("/api/ai/jobs")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.jobs?.length) {
|
if (data.jobs?.length) {
|
||||||
@@ -56,28 +73,13 @@ export function AIChat() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
checkServer()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
const AI_API = process.env.NEXT_PUBLIC_AI_URL || "http://127.0.0.1:3001"
|
|
||||||
|
|
||||||
const checkOllama = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${AI_API}/health`)
|
|
||||||
const data = await res.json()
|
|
||||||
setOllamaStatus(data.status === "ok")
|
|
||||||
} catch {
|
|
||||||
setOllamaStatus(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { checkOllama() }, [])
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
const msg = input.trim()
|
const msg = input.trim()
|
||||||
if (!msg || loading) return
|
if (!msg || loading) return
|
||||||
@@ -88,7 +90,7 @@ export function AIChat() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${AI_API}/ai/chat`, {
|
const res = await fetch("/api/ai/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message: msg }),
|
body: JSON.stringify({ message: msg }),
|
||||||
@@ -120,17 +122,86 @@ export function AIChat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bootOverlay = (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<style>{`
|
||||||
|
@keyframes walk {
|
||||||
|
0%, 100% { transform: translateX(0) translateY(0); }
|
||||||
|
25% { transform: translateX(12px) translateY(-4px); }
|
||||||
|
50% { transform: translateX(24px) translateY(0); }
|
||||||
|
75% { transform: translateX(12px) translateY(-4px); }
|
||||||
|
}
|
||||||
|
@keyframes legLeft {
|
||||||
|
0%, 100% { transform: rotate(-10deg); }
|
||||||
|
50% { transform: rotate(10deg); }
|
||||||
|
}
|
||||||
|
@keyframes legRight {
|
||||||
|
0%, 100% { transform: rotate(10deg); }
|
||||||
|
50% { transform: rotate(-10deg); }
|
||||||
|
}
|
||||||
|
@keyframes armLeft {
|
||||||
|
0%, 100% { transform: rotate(15deg); }
|
||||||
|
50% { transform: rotate(-15deg); }
|
||||||
|
}
|
||||||
|
@keyframes armRight {
|
||||||
|
0%, 100% { transform: rotate(-15deg); }
|
||||||
|
50% { transform: rotate(15deg); }
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 45%, 55%, 100% { height: 4px; }
|
||||||
|
50% { height: 1px; }
|
||||||
|
}
|
||||||
|
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
|
||||||
|
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
|
||||||
|
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
|
||||||
|
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
|
||||||
|
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
|
||||||
|
.robot-eye { animation: blink 2s ease-in-out infinite; }
|
||||||
|
`}</style>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<p className="text-sm text-[#6a6a75]">Servers booting...</p>
|
||||||
|
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden">
|
||||||
|
<div className="robot-walk relative">
|
||||||
|
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
|
||||||
|
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/>
|
||||||
|
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/>
|
||||||
|
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/>
|
||||||
|
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
||||||
|
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
||||||
|
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
||||||
|
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
||||||
|
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/>
|
||||||
|
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/>
|
||||||
|
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const readyOverlay = (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
|
||||||
|
<Check className="h-6 w-6 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
|
||||||
|
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (bootState === "booting") return bootOverlay
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{ollamaStatus === false && (
|
{bootState === "ready" && readyOverlay}
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
|
|
||||||
<AlertCircle className="h-3.5 w-3.5 flex-none" />
|
|
||||||
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
|
|
||||||
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
@@ -162,7 +233,10 @@ export function AIChat() {
|
|||||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
|
<svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -191,7 +265,12 @@ export function AIChat() {
|
|||||||
disabled={loading || !input.trim()}
|
disabled={loading || !input.trim()}
|
||||||
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
|
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
{loading ? (
|
||||||
|
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
) : <Send className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { Phone, X, Copy, MessageSquare } from "lucide-react"
|
||||||
|
import { getSupabase } from "@/lib/supabase"
|
||||||
|
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
avatar_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceCallModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhoneForWhatsApp(phone: string): string {
|
||||||
|
const digits = phone.replace(/[^0-9]/g, "")
|
||||||
|
if (!digits) return ""
|
||||||
|
if (digits.startsWith("27")) return digits
|
||||||
|
if (digits.startsWith("0")) return "27" + digits.slice(1)
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
|
const [contactsLoading, setContactsLoading] = useState(false)
|
||||||
|
const [contactsError, setContactsError] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState("")
|
||||||
|
const [phoneError, setPhoneError] = useState(false)
|
||||||
|
|
||||||
|
const [callLink, setCallLink] = useState<string | null>(null)
|
||||||
|
const [shareSent, setShareSent] = useState(false)
|
||||||
|
const [shareError, setShareError] = useState<string | null>(null)
|
||||||
|
const [targetPhone, setTargetPhone] = useState<string>("")
|
||||||
|
const [showWaUrl, setShowWaUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { createRoom } = useWebRTCCall()
|
||||||
|
|
||||||
|
const handleCall = useCallback((phone?: string) => {
|
||||||
|
setShareSent(false)
|
||||||
|
setShareError(null)
|
||||||
|
const { roomId, link } = createRoom()
|
||||||
|
setCallLink(link)
|
||||||
|
setTargetPhone(phone || "")
|
||||||
|
console.log("[call] room created, roomId:", roomId)
|
||||||
|
console.log("[call] join link:", link)
|
||||||
|
console.log("[call] target phone:", phone || "none")
|
||||||
|
}, [createRoom])
|
||||||
|
|
||||||
|
const copyLink = useCallback(async () => {
|
||||||
|
if (!callLink) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(callLink)
|
||||||
|
setShareSent(true)
|
||||||
|
setShareError(null)
|
||||||
|
console.log("[share] link copied")
|
||||||
|
} catch {
|
||||||
|
setShareError("Failed to copy URL")
|
||||||
|
console.error("[share] copy failed")
|
||||||
|
}
|
||||||
|
}, [callLink])
|
||||||
|
|
||||||
|
const sendWhatsApp = useCallback(() => {
|
||||||
|
if (!callLink) return
|
||||||
|
|
||||||
|
const originalPhone = targetPhone
|
||||||
|
const formattedPhone = formatPhoneForWhatsApp(originalPhone)
|
||||||
|
const generatedCallLink = callLink
|
||||||
|
|
||||||
|
console.log("[wa] original phone:", originalPhone)
|
||||||
|
console.log("[wa] formatted phone:", formattedPhone)
|
||||||
|
console.log("[wa] call link:", generatedCallLink)
|
||||||
|
|
||||||
|
if (!formattedPhone) {
|
||||||
|
setShareError("Invalid phone number.")
|
||||||
|
console.error("[wa] invalid phone number")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = encodeURIComponent(`Hi! Join me on this link so we can start our call:\n\n${generatedCallLink}`)
|
||||||
|
const waUrl = `https://wa.me/${formattedPhone}?text=${msg}`
|
||||||
|
|
||||||
|
console.log("[wa] WhatsApp URL:", waUrl)
|
||||||
|
setShowWaUrl(waUrl)
|
||||||
|
|
||||||
|
const w = window.open(waUrl, "_blank")
|
||||||
|
if (!w || w.closed) {
|
||||||
|
setShareError("Could not open WhatsApp. Please check your browser allows popups.")
|
||||||
|
console.error("[wa] failed: window.open returned null or closed")
|
||||||
|
} else {
|
||||||
|
setShareSent(true)
|
||||||
|
setShareError(null)
|
||||||
|
console.log("[wa] success: WhatsApp opened")
|
||||||
|
}
|
||||||
|
}, [callLink, targetPhone])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearchQuery("")
|
||||||
|
setPhoneNumber("")
|
||||||
|
setPhoneError(false)
|
||||||
|
setCallLink(null)
|
||||||
|
setShareSent(false)
|
||||||
|
setShareError(null)
|
||||||
|
setTargetPhone("")
|
||||||
|
setShowWaUrl(null)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const FALLBACK_CONTACTS: Contact[] = [
|
||||||
|
{ id: "fallback-dillen", name: "Dillen", phone: "0799158142", avatar_url: null },
|
||||||
|
{ id: "fallback-ewan", name: "Ewan", phone: "0845172665", avatar_url: null },
|
||||||
|
{ id: "fallback-caitlin", name: "Caitlin", phone: "0612729281", avatar_url: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const fetchContacts = async () => {
|
||||||
|
setContactsLoading(true)
|
||||||
|
setContactsError(false)
|
||||||
|
try {
|
||||||
|
const sb = getSupabase()
|
||||||
|
const { data, error } = await sb
|
||||||
|
.from("contacts")
|
||||||
|
.select("id, name, phone, avatar_url")
|
||||||
|
.order("name", { ascending: true })
|
||||||
|
if (error) throw error
|
||||||
|
setContacts([...(data || []), ...FALLBACK_CONTACTS])
|
||||||
|
} catch {
|
||||||
|
setContacts(FALLBACK_CONTACTS)
|
||||||
|
} finally {
|
||||||
|
setContactsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchContacts()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
const filteredContacts = contacts.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.phone.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{callLink ? (
|
||||||
|
shareSent ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p>
|
||||||
|
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2>
|
||||||
|
<p className="text-[#888888] text-sm mt-1 mb-4">
|
||||||
|
The recipient must receive and open the call link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
||||||
|
{callLink}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={copyLink}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copy URL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShareSent(false); setShareError(null) }}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
WhatsApp
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={callLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
|
||||||
|
>
|
||||||
|
Open Call
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2>
|
||||||
|
<p className="text-[#888888] text-sm mt-1 mb-4">
|
||||||
|
Select a method to send the call link
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
||||||
|
{callLink}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showWaUrl && (
|
||||||
|
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono">
|
||||||
|
{showWaUrl}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={sendWhatsApp}
|
||||||
|
className="flex items-center justify-center gap-2 bg-[#25D366] hover:bg-[#1da851] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
WhatsApp
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={copyLink}
|
||||||
|
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copy URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shareError && (
|
||||||
|
<p className="text-[#CC0000] text-xs text-center">{shareError}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2>
|
||||||
|
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p>
|
||||||
|
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||||
|
CONTACTS
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search contacts..."
|
||||||
|
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
|
||||||
|
{contactsLoading && (
|
||||||
|
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
|
||||||
|
)}
|
||||||
|
{contactsError && (
|
||||||
|
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p>
|
||||||
|
)}
|
||||||
|
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
|
||||||
|
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p>
|
||||||
|
)}
|
||||||
|
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
|
||||||
|
<div
|
||||||
|
key={contact.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]"
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
|
||||||
|
{contact.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={contact.avatar_url}
|
||||||
|
alt={contact.name}
|
||||||
|
className="w-full h-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
contact.name.charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p>
|
||||||
|
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCall(contact.phone)}
|
||||||
|
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 my-4">
|
||||||
|
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||||
|
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span>
|
||||||
|
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||||
|
DIAL A NUMBER
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
|
||||||
|
placeholder="+27 000 000 0000"
|
||||||
|
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||||
|
/>
|
||||||
|
{phoneError && (
|
||||||
|
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = phoneNumber.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setPhoneError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPhoneError(false)
|
||||||
|
handleCall(trimmed)
|
||||||
|
}}
|
||||||
|
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
Call Now
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import { usePathname } from "next/navigation"
|
|||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { Sidebar } from "./sidebar"
|
import { Sidebar } from "./sidebar"
|
||||||
import { Topbar } from "./topbar"
|
import { Topbar } from "./topbar"
|
||||||
import { SystemMonitor } from "./system-monitor"
|
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -36,51 +35,50 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
|
backgroundImage: "radial-gradient(circle, #e6202010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
|
||||||
backgroundSize: "28px 28px, 14px 14px",
|
backgroundSize: "28px 28px, 14px 14px",
|
||||||
backgroundPosition: "0 0, 7px 7px",
|
backgroundPosition: "0 0, 7px 7px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Spider-Man top gradient bar */}
|
{/* Spider-Man top gradient bar */}
|
||||||
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
|
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#e62020] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF6666] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
|
||||||
|
|
||||||
{/* Corner spider webs */}
|
{/* Corner spider webs */}
|
||||||
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
||||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="0" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
||||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
|
||||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="0" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SystemMonitor />
|
|
||||||
<Sidebar
|
<Sidebar
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onToggle={toggleSidebar}
|
onToggle={toggleSidebar}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link"
|
|||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { SystemMonitor } from "./system-monitor"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Facebook,
|
Facebook,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { COMPANY_NAME } from "@/lib/constants"
|
|
||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
import { useNotifications } from "@/providers/notification-provider"
|
import { useNotifications } from "@/providers/notification-provider"
|
||||||
import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-dialog"
|
import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-dialog"
|
||||||
@@ -60,24 +61,11 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
|
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
|
||||||
<Link href="/" className="flex items-center gap-3 overflow-hidden">
|
<Link href="/" className="flex items-center gap-3 overflow-hidden">
|
||||||
<img
|
{collapsed ? (
|
||||||
src="/logo/CompanyLogo.png"
|
<div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div>
|
||||||
alt={COMPANY_NAME}
|
) : (
|
||||||
className="h-8 w-8 shrink-0 rounded-lg object-contain"
|
<div className="bc-logo">Black<span className="accent">Cipher</span></div>
|
||||||
/>
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{!collapsed && (
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -10 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="text-sm font-semibold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{COMPANY_NAME}
|
|
||||||
</motion.span>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
</Link>
|
</Link>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<Button
|
<Button
|
||||||
@@ -196,6 +184,8 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SystemMonitor collapsed={collapsed} />
|
||||||
|
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}>
|
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
@@ -222,7 +212,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<aside className="hidden lg:fixed lg:inset-y-0 lg:z-30 lg:flex">
|
<aside className="hidden lg:fixed lg:inset-y-0 lg:z-30 lg:flex border-r border-sidebar-border">
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -244,7 +234,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: -300 }}
|
exit={{ x: -300 }}
|
||||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||||
className="fixed inset-y-0 left-0 z-50 lg:hidden"
|
className="fixed inset-y-0 left-0 z-50 lg:hidden border-r border-sidebar-border"
|
||||||
>
|
>
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
const RAM_LIMIT_MB = 8192
|
const RAM_LIMIT_MB = 8192
|
||||||
const CPU_LIMIT_PCT = 400 // 4 cores * 100%
|
const CPU_LIMIT_PCT = 400
|
||||||
|
|
||||||
export function SystemMonitor() {
|
interface SystemMonitorProps {
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemMonitor({ collapsed }: SystemMonitorProps) {
|
||||||
const [rssMB, setRssMB] = useState(0)
|
const [rssMB, setRssMB] = useState(0)
|
||||||
const [cpuPct, setCpuPct] = useState(0)
|
const [cpuPct, setCpuPct] = useState(0)
|
||||||
|
|
||||||
@@ -30,14 +34,33 @@ export function SystemMonitor() {
|
|||||||
const ramOver = rssMB > RAM_LIMIT_MB
|
const ramOver = rssMB > RAM_LIMIT_MB
|
||||||
const cpuOver = cpuPct > CPU_LIMIT_PCT
|
const cpuOver = cpuPct > CPU_LIMIT_PCT
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 z-[9999] flex items-center gap-3 px-3 py-1 text-[11px] font-mono bg-black/80 rounded-br-lg select-none">
|
<div className="border-t border-sidebar-border px-3 py-2 flex justify-center gap-1.5">
|
||||||
<span className={ramOver ? "text-red-400" : "text-green-400"}>
|
<span className={ramOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
|
||||||
RAM: {rssMB}MB / 8192MB
|
{rssMB}
|
||||||
</span>
|
</span>
|
||||||
<span className={cpuOver ? "text-red-400" : "text-green-400"}>
|
<span className={cpuOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
|
||||||
CPU: {cpuPct}%
|
{cpuPct}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-sidebar-border px-3 py-2.5">
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-mono">
|
||||||
|
<span className="text-sidebar-foreground/50">RAM</span>
|
||||||
|
<span className={ramOver ? "text-red-400 font-semibold" : "text-sidebar-foreground/80"}>
|
||||||
|
{rssMB}MB / {RAM_LIMIT_MB}MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-mono mt-1">
|
||||||
|
<span className="text-sidebar-foreground/50">CPU</span>
|
||||||
|
<span className={cpuOver ? "text-red-400 font-semibold" : "text-sidebar-foreground/80"}>
|
||||||
|
{cpuPct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { useUser } from "@/providers/user-provider";
|
import { useUser } from "@/providers/user-provider";
|
||||||
import { useNotifications } from "@/providers/notification-provider";
|
import { useNotifications } from "@/providers/notification-provider";
|
||||||
|
import { BugReportModal } from "@/components/shared/bug-report-modal";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
Settings,
|
Settings,
|
||||||
|
Bug,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -43,6 +45,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
useNotifications();
|
useNotifications();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [bugModalOpen, setBugModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -113,6 +116,17 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
<Search className="h-5 w-5" />
|
<Search className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Report a Bug */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setBugModalOpen(true)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title="Report a Bug"
|
||||||
|
>
|
||||||
|
<Bug className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Theme toggle */}
|
{/* Theme toggle */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -247,6 +261,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
<BugReportModal open={bugModalOpen} onClose={() => setBugModalOpen(false)} />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Sun, Moon, Monitor } from "lucide-react"
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
import { Sun, Moon, Monitor, Check } from "lucide-react"
|
||||||
|
|
||||||
const COLOR_THEME_KEY = "crm-color-theme"
|
const COLOR_THEME_KEY = "crm-color-theme"
|
||||||
|
|
||||||
@@ -44,8 +45,17 @@ function applyColorTheme(theme: string) {
|
|||||||
localStorage.setItem(COLOR_THEME_KEY, theme)
|
localStorage.setItem(COLOR_THEME_KEY, theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const websiteThemes = [
|
||||||
|
{
|
||||||
|
id: "spidey",
|
||||||
|
name: "Spidey",
|
||||||
|
description: "Current website theme",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function ThemeSettings() {
|
export function ThemeSettings() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
|
||||||
const [colorTheme, setColorTheme] = useState("default")
|
const [colorTheme, setColorTheme] = useState("default")
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
@@ -119,6 +129,71 @@ export function ThemeSettings() {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Website Themes</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Select your website's visual theme.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||||
|
{websiteThemes.map((wt) => {
|
||||||
|
const isActive = websiteTheme === wt.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={wt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWebsiteTheme(wt.id)}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col items-start gap-3 rounded-xl border-2 p-4 text-left transition-all",
|
||||||
|
"hover:bg-accent cursor-pointer",
|
||||||
|
isActive
|
||||||
|
? "border-primary bg-primary/5 shadow-sm"
|
||||||
|
: "border-border hover:border-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex h-20 w-full items-center justify-center overflow-hidden rounded-lg border border-border bg-background">
|
||||||
|
<div className="flex h-full w-full">
|
||||||
|
<div className="flex w-1/3 flex-col gap-0.5 bg-[#0a0a0f] p-1.5">
|
||||||
|
<div className="h-1 w-full rounded-sm bg-[#1a1a24]" />
|
||||||
|
<div className="h-1 w-2/3 rounded-sm bg-[#1a1a24]" />
|
||||||
|
<div className="mt-auto h-1.5 w-full rounded-sm bg-[#1a1a24]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<div className="flex h-5 items-center gap-1 bg-[#CC0000] px-1.5">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center bg-[#141414]">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-[#CC0000]/40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">{wt.name}</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
|
import { X, Bug, Loader2, CheckCircle } from "lucide-react"
|
||||||
|
|
||||||
|
interface BugReportModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [severity, setSeverity] = useState("medium")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bug-reports", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
severity,
|
||||||
|
page_url: window.location.href,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSubmitted(true)
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setSeverity("medium")
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
setError(data.error || "Failed to submit bug report.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Connection error. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSubmitted(false)
|
||||||
|
setError("")
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
className="relative w-full max-w-md rounded-xl border bg-white dark:bg-[#141414] p-6 shadow-2xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute right-4 top-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{submitted ? (
|
||||||
|
<div className="flex flex-col items-center py-8 text-center">
|
||||||
|
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-[#111111] dark:text-white mb-2">
|
||||||
|
Bug Report Submitted
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[#666666] dark:text-[#AAAAAA]">
|
||||||
|
Thank you for your report. The development team will investigate.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="mt-6 rounded-lg bg-[#CC0000] px-6 py-2 text-sm font-medium text-white hover:bg-[#AA0000] transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
|
||||||
|
<Bug className="h-5 w-5 text-[#CC0000]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[#111111] dark:text-white">
|
||||||
|
Report a Bug
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#666666] dark:text-[#AAAAAA]">
|
||||||
|
Help us improve the system
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-500/10 px-4 py-2 text-sm text-red-500">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||||
|
Title <span className="text-[#CC0000]">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Brief description of the issue"
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||||
|
Description <span className="text-[#CC0000]">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What happened? What did you expect?"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
className="w-full resize-none rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||||
|
Severity
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={severity}
|
||||||
|
onChange={(e) => setSeverity(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||||
|
>
|
||||||
|
<option value="low">Low — Minor cosmetic issue</option>
|
||||||
|
<option value="medium">Medium — Affects functionality</option>
|
||||||
|
<option value="high">High — Major feature broken</option>
|
||||||
|
<option value="critical">Critical — System is blocked</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] px-3 py-2">
|
||||||
|
<p className="text-xs text-[#888888]">
|
||||||
|
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#CC0000] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#AA0000] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{loading ? "Submitting..." : "Submit Bug Report"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export const users: User[] = [
|
|||||||
id: "u-super",
|
id: "u-super",
|
||||||
name: "Jason Oosthuizen",
|
name: "Jason Oosthuizen",
|
||||||
email: "jason@coastalit.com",
|
email: "jason@coastalit.com",
|
||||||
|
phone: "+27 82 555 0100",
|
||||||
role: "super_admin",
|
role: "super_admin",
|
||||||
active: true,
|
active: true,
|
||||||
avatar: "https://ui-avatars.com/api/?name=Jason+Oosthuizen&background=0f172a&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=Jason+Oosthuizen&background=0f172a&color=fff&size=128",
|
||||||
@@ -14,6 +15,7 @@ export const users: User[] = [
|
|||||||
id: "u1",
|
id: "u1",
|
||||||
name: "Sarah Chen",
|
name: "Sarah Chen",
|
||||||
email: "SarahChen@coastit.co.za",
|
email: "SarahChen@coastit.co.za",
|
||||||
|
phone: "+27 72 555 0101",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
active: true,
|
active: true,
|
||||||
avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128",
|
||||||
@@ -23,6 +25,7 @@ export const users: User[] = [
|
|||||||
id: "u2",
|
id: "u2",
|
||||||
name: "Marcus Johnson",
|
name: "Marcus Johnson",
|
||||||
email: "marcus@coastalit.com",
|
email: "marcus@coastalit.com",
|
||||||
|
phone: "+27 62 555 0102",
|
||||||
role: "sales",
|
role: "sales",
|
||||||
active: true,
|
active: true,
|
||||||
avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128",
|
||||||
@@ -32,6 +35,7 @@ export const users: User[] = [
|
|||||||
id: "u3",
|
id: "u3",
|
||||||
name: "Emily Rodriguez",
|
name: "Emily Rodriguez",
|
||||||
email: "emily@coastalit.com",
|
email: "emily@coastalit.com",
|
||||||
|
phone: "+27 73 555 0103",
|
||||||
role: "sales",
|
role: "sales",
|
||||||
active: true,
|
active: true,
|
||||||
avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128",
|
||||||
@@ -41,6 +45,7 @@ export const users: User[] = [
|
|||||||
id: "u4",
|
id: "u4",
|
||||||
name: "David Kim",
|
name: "David Kim",
|
||||||
email: "david@coastalit.com",
|
email: "david@coastalit.com",
|
||||||
|
phone: "+27 64 555 0104",
|
||||||
role: "sales",
|
role: "sales",
|
||||||
active: true,
|
active: true,
|
||||||
avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128",
|
||||||
@@ -50,6 +55,7 @@ export const users: User[] = [
|
|||||||
id: "u5",
|
id: "u5",
|
||||||
name: "Jessica Patel",
|
name: "Jessica Patel",
|
||||||
email: "jessica@coastalit.com",
|
email: "jessica@coastalit.com",
|
||||||
|
phone: "+27 82 555 0105",
|
||||||
role: "sales",
|
role: "sales",
|
||||||
active: false,
|
active: false,
|
||||||
avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128",
|
||||||
@@ -59,6 +65,7 @@ export const users: User[] = [
|
|||||||
id: "u6",
|
id: "u6",
|
||||||
name: "Alex Thompson",
|
name: "Alex Thompson",
|
||||||
email: "alex@coastalit.com",
|
email: "alex@coastalit.com",
|
||||||
|
phone: "+27 72 555 0106",
|
||||||
role: "sales",
|
role: "sales",
|
||||||
active: true,
|
active: true,
|
||||||
avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128",
|
||||||
@@ -68,6 +75,7 @@ export const users: User[] = [
|
|||||||
id: "u7",
|
id: "u7",
|
||||||
name: "Rachel Williams",
|
name: "Rachel Williams",
|
||||||
email: "rachel@coastalit.com",
|
email: "rachel@coastalit.com",
|
||||||
|
phone: "+27 64 555 0107",
|
||||||
role: "sales",
|
role: "sales",
|
||||||
active: true,
|
active: true,
|
||||||
avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128",
|
||||||
@@ -77,6 +85,7 @@ export const users: User[] = [
|
|||||||
id: "u8",
|
id: "u8",
|
||||||
name: "Tom Nakamura",
|
name: "Tom Nakamura",
|
||||||
email: "tom@coastalit.com",
|
email: "tom@coastalit.com",
|
||||||
|
phone: "+27 83 555 0108",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
active: true,
|
active: true,
|
||||||
avatar: "https://ui-avatars.com/api/?name=Tom+Nakamura&background=3b82f6&color=fff&size=128",
|
avatar: "https://ui-avatars.com/api/?name=Tom+Nakamura&background=3b82f6&color=fff&size=128",
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react"
|
||||||
|
import { io, Socket } from "socket.io-client"
|
||||||
|
|
||||||
|
const STUN_SERVERS = {
|
||||||
|
iceServers: [
|
||||||
|
{ urls: "stun:stun.l.google.com:19302" },
|
||||||
|
{ urls: "stun:stun1.l.google.com:19302" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNALING_URL = process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007"
|
||||||
|
|
||||||
|
export type CallState = "idle" | "calling" | "ringing" | "connecting" | "connected" | "ended" | "missed" | "rejected" | "offline" | "busy" | "waiting" | "participant_joined"
|
||||||
|
|
||||||
|
export interface PlatformUser {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
phone: string | null
|
||||||
|
avatar: string | null
|
||||||
|
online: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallerInfo {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
avatar: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Participant {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebRTCCall(options?: { anonymous?: boolean }) {
|
||||||
|
const isAnonymous = options?.anonymous ?? false
|
||||||
|
|
||||||
|
const [callState, setCallState] = useState<CallState>("idle")
|
||||||
|
const [isMuted, setIsMuted] = useState(false)
|
||||||
|
const [isSpeakerOn, setIsSpeakerOn] = useState(false)
|
||||||
|
const [callDuration, setCallDuration] = useState(0)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null)
|
||||||
|
const [callerInfo, setCallerInfo] = useState<CallerInfo | null>(null)
|
||||||
|
const [calleeNumber, setCalleeNumber] = useState<string>("")
|
||||||
|
const [tokenLoading, setTokenLoading] = useState(true)
|
||||||
|
const [participants, setParticipants] = useState<Participant[]>([])
|
||||||
|
|
||||||
|
const socketRef = useRef<Socket | null>(null)
|
||||||
|
const pcRef = useRef<RTCPeerConnection | null>(null)
|
||||||
|
const localStreamRef = useRef<MediaStream | null>(null)
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const pendingCallRef = useRef<{ to: string; sdp: any } | null>(null)
|
||||||
|
const roomIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const startTimer = useCallback(() => {
|
||||||
|
setCallDuration(0)
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setCallDuration((prev) => prev + 1)
|
||||||
|
}, 1000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let socket: Socket | null = null
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
let authToken: string | undefined
|
||||||
|
|
||||||
|
if (!isAnonymous) {
|
||||||
|
const res = await fetch("/api/auth/jwt")
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
authToken = data.token
|
||||||
|
}
|
||||||
|
if (cancelled) return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = io(SIGNALING_URL, {
|
||||||
|
auth: authToken ? { token: authToken } : undefined,
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("connect", () => setIsReady(true))
|
||||||
|
socket.on("disconnect", () => setIsReady(false))
|
||||||
|
|
||||||
|
if (!isAnonymous) {
|
||||||
|
socket.on("call:incoming", ({ from, sdp, callerInfo: info }) => {
|
||||||
|
setCallerInfo(info)
|
||||||
|
setCallState("ringing")
|
||||||
|
pendingCallRef.current = { to: from, sdp }
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:answered", async ({ from, sdp }) => {
|
||||||
|
setCallState("connecting")
|
||||||
|
if (pcRef.current && sdp) {
|
||||||
|
try {
|
||||||
|
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:ice-candidate", async ({ from, candidate }) => {
|
||||||
|
if (pcRef.current && candidate) {
|
||||||
|
try {
|
||||||
|
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:ended", ({ from }) => {
|
||||||
|
setCallState("ended")
|
||||||
|
cleanupCall()
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:rejected", ({ from }) => {
|
||||||
|
setCallState("rejected")
|
||||||
|
cleanupCall()
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:busy", ({ from }) => {
|
||||||
|
setCallState("busy")
|
||||||
|
cleanupCall()
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("call:user-offline", ({ userId }) => {
|
||||||
|
setCallState("offline")
|
||||||
|
cleanupCall()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on("room:waiting", ({ roomId, participants: pList }) => {
|
||||||
|
setParticipants(pList || [])
|
||||||
|
setCallState("waiting")
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:participant-joined", ({ roomId, participants: pList }) => {
|
||||||
|
setParticipants(pList || [])
|
||||||
|
setCallState("participant_joined")
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:initiate-offer", async ({ roomId, participants: pList }) => {
|
||||||
|
setParticipants(pList || [])
|
||||||
|
setCallState("participant_joined")
|
||||||
|
try {
|
||||||
|
const pc = new RTCPeerConnection(STUN_SERVERS)
|
||||||
|
pc.onicecandidate = (e) => {
|
||||||
|
if (e.candidate && roomIdRef.current) {
|
||||||
|
socket?.emit("room:ice-candidate", {
|
||||||
|
roomId: roomIdRef.current,
|
||||||
|
candidate: e.candidate.toJSON(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pc.ontrack = (e) => {
|
||||||
|
setRemoteStream(e.streams[0])
|
||||||
|
const audio = new Audio()
|
||||||
|
audio.srcObject = e.streams[0]
|
||||||
|
audio.autoplay = true
|
||||||
|
audioRef.current = audio
|
||||||
|
audio.play().catch(() => {})
|
||||||
|
}
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
||||||
|
setCallState("ended")
|
||||||
|
cleanupCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||||
|
localStreamRef.current = stream
|
||||||
|
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
|
||||||
|
pcRef.current = pc
|
||||||
|
|
||||||
|
const offer = await pc.createOffer()
|
||||||
|
await pc.setLocalDescription(offer)
|
||||||
|
setCallState("connecting")
|
||||||
|
socket?.emit("room:offer", { roomId, sdp: pc.localDescription })
|
||||||
|
} catch (e) {
|
||||||
|
setError("Failed to start call")
|
||||||
|
cleanupCall()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:offer", async ({ sdp }) => {
|
||||||
|
try {
|
||||||
|
setCallState("connecting")
|
||||||
|
const pc = new RTCPeerConnection(STUN_SERVERS)
|
||||||
|
pc.onicecandidate = (e) => {
|
||||||
|
if (e.candidate && roomIdRef.current) {
|
||||||
|
socket?.emit("room:ice-candidate", {
|
||||||
|
roomId: roomIdRef.current,
|
||||||
|
candidate: e.candidate.toJSON(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pc.ontrack = (e) => {
|
||||||
|
setRemoteStream(e.streams[0])
|
||||||
|
const audio = new Audio()
|
||||||
|
audio.srcObject = e.streams[0]
|
||||||
|
audio.autoplay = true
|
||||||
|
audioRef.current = audio
|
||||||
|
audio.play().catch(() => {})
|
||||||
|
}
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
||||||
|
setCallState("ended")
|
||||||
|
cleanupCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||||
|
localStreamRef.current = stream
|
||||||
|
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
|
||||||
|
pcRef.current = pc
|
||||||
|
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(sdp))
|
||||||
|
const answer = await pc.createAnswer()
|
||||||
|
await pc.setLocalDescription(answer)
|
||||||
|
socket?.emit("room:answer", { roomId: roomIdRef.current, sdp: pc.localDescription })
|
||||||
|
setCallState("connected")
|
||||||
|
startTimer()
|
||||||
|
} catch (e) {
|
||||||
|
setError("Failed to join call")
|
||||||
|
cleanupCall()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:answer", async ({ sdp }) => {
|
||||||
|
if (pcRef.current) {
|
||||||
|
try {
|
||||||
|
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
|
||||||
|
setCallState("connected")
|
||||||
|
startTimer()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:ice-candidate", async ({ candidate }) => {
|
||||||
|
if (pcRef.current) {
|
||||||
|
try {
|
||||||
|
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("room:participant-left", ({ roomId, participants: pList }) => {
|
||||||
|
setParticipants(pList || [])
|
||||||
|
setCallState("ended")
|
||||||
|
cleanupCall()
|
||||||
|
})
|
||||||
|
|
||||||
|
socketRef.current = socket
|
||||||
|
} finally {
|
||||||
|
setTokenLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (socket) socket.disconnect()
|
||||||
|
cleanupCall()
|
||||||
|
}
|
||||||
|
}, [isAnonymous])
|
||||||
|
|
||||||
|
const cleanupCall = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
if (pcRef.current) {
|
||||||
|
pcRef.current.close()
|
||||||
|
pcRef.current = null
|
||||||
|
}
|
||||||
|
if (localStreamRef.current) {
|
||||||
|
localStreamRef.current.getTracks().forEach((t) => t.stop())
|
||||||
|
localStreamRef.current = null
|
||||||
|
}
|
||||||
|
setRemoteStream(null)
|
||||||
|
setIsMuted(false)
|
||||||
|
setIsSpeakerOn(false)
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause()
|
||||||
|
audioRef.current.srcObject = null
|
||||||
|
audioRef.current = null
|
||||||
|
}
|
||||||
|
setCallDuration(0)
|
||||||
|
pendingCallRef.current = null
|
||||||
|
roomIdRef.current = null
|
||||||
|
setParticipants([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createPeerConnection = useCallback(async () => {
|
||||||
|
const pc = new RTCPeerConnection(STUN_SERVERS)
|
||||||
|
|
||||||
|
pc.onicecandidate = (e) => {
|
||||||
|
if (e.candidate && pendingCallRef.current) {
|
||||||
|
socketRef.current?.emit("call:ice-candidate", {
|
||||||
|
to: pendingCallRef.current.to,
|
||||||
|
candidate: e.candidate.toJSON(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.ontrack = (e) => {
|
||||||
|
setRemoteStream(e.streams[0])
|
||||||
|
const audio = new Audio()
|
||||||
|
audio.srcObject = e.streams[0]
|
||||||
|
audio.autoplay = true
|
||||||
|
audioRef.current = audio
|
||||||
|
audio.play().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
||||||
|
setCallState("ended")
|
||||||
|
cleanupCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||||
|
localStreamRef.current = stream
|
||||||
|
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
|
||||||
|
} catch {
|
||||||
|
setError("Microphone access denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
pcRef.current = pc
|
||||||
|
return pc
|
||||||
|
}, [cleanupCall])
|
||||||
|
|
||||||
|
const lookupUser = useCallback(async (phone: string): Promise<PlatformUser | null> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
socketRef.current?.emit("user:lookup", { phone }, (result: any) => {
|
||||||
|
if (result?.found) {
|
||||||
|
resolve(result.user)
|
||||||
|
} else {
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const makeCall = useCallback(async (phoneNumber: string) => {
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
setCalleeNumber(phoneNumber)
|
||||||
|
|
||||||
|
const user = await lookupUser(phoneNumber)
|
||||||
|
if (!user) {
|
||||||
|
setCallState("missed")
|
||||||
|
setError("Not on Platform")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!user.online) {
|
||||||
|
setCallState("offline")
|
||||||
|
setError("User is offline")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc = await createPeerConnection()
|
||||||
|
setCallState("calling")
|
||||||
|
|
||||||
|
const offer = await pc.createOffer()
|
||||||
|
await pc.setLocalDescription(offer)
|
||||||
|
|
||||||
|
socketRef.current?.emit("call:offer", {
|
||||||
|
to: user.id,
|
||||||
|
sdp: pc.localDescription,
|
||||||
|
callerInfo: null,
|
||||||
|
})
|
||||||
|
pendingCallRef.current = { to: user.id, sdp: null }
|
||||||
|
|
||||||
|
setCallState("ringing")
|
||||||
|
} catch {
|
||||||
|
setError("Failed to start call")
|
||||||
|
cleanupCall()
|
||||||
|
}
|
||||||
|
}, [createPeerConnection, lookupUser, cleanupCall])
|
||||||
|
|
||||||
|
const acceptCall = useCallback(async () => {
|
||||||
|
if (!pendingCallRef.current) return
|
||||||
|
setCallState("connecting")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pc = await createPeerConnection()
|
||||||
|
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(pendingCallRef.current.sdp))
|
||||||
|
const answer = await pc.createAnswer()
|
||||||
|
await pc.setLocalDescription(answer)
|
||||||
|
|
||||||
|
socketRef.current?.emit("call:answer", {
|
||||||
|
to: pendingCallRef.current.to,
|
||||||
|
sdp: pc.localDescription,
|
||||||
|
})
|
||||||
|
|
||||||
|
setCallState("connected")
|
||||||
|
startTimer()
|
||||||
|
} catch {
|
||||||
|
setError("Failed to accept call")
|
||||||
|
cleanupCall()
|
||||||
|
}
|
||||||
|
}, [createPeerConnection, cleanupCall, startTimer])
|
||||||
|
|
||||||
|
const rejectCall = useCallback(() => {
|
||||||
|
if (pendingCallRef.current) {
|
||||||
|
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
|
||||||
|
}
|
||||||
|
setCallState("idle")
|
||||||
|
cleanupCall()
|
||||||
|
}, [cleanupCall])
|
||||||
|
|
||||||
|
const endCall = useCallback(() => {
|
||||||
|
if (roomIdRef.current) {
|
||||||
|
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
|
||||||
|
}
|
||||||
|
if (!isAnonymous) {
|
||||||
|
if (callState === "ringing" && pendingCallRef.current) {
|
||||||
|
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
|
||||||
|
}
|
||||||
|
if (callState === "connected" || callState === "connecting" || callState === "calling") {
|
||||||
|
const to = pendingCallRef.current?.to
|
||||||
|
if (to) {
|
||||||
|
socketRef.current?.emit("call:end", { to })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCallState("ended")
|
||||||
|
cleanupCall()
|
||||||
|
}, [callState, cleanupCall, isAnonymous])
|
||||||
|
|
||||||
|
const toggleSpeaker = useCallback(() => {
|
||||||
|
setIsSpeakerOn((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
if (localStreamRef.current) {
|
||||||
|
localStreamRef.current.getAudioTracks().forEach((track) => {
|
||||||
|
track.enabled = isMuted
|
||||||
|
})
|
||||||
|
setIsMuted(!isMuted)
|
||||||
|
}
|
||||||
|
}, [isMuted])
|
||||||
|
|
||||||
|
const formatDuration = useCallback((seconds: number) => {
|
||||||
|
const m = Math.floor(seconds / 60).toString().padStart(2, "0")
|
||||||
|
const s = (seconds % 60).toString().padStart(2, "0")
|
||||||
|
return `${m}:${s}`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createRoom = useCallback(() => {
|
||||||
|
const roomId = crypto.randomUUID?.() || Math.random().toString(36).slice(2, 14)
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || (typeof window !== "undefined" ? window.location.origin : "http://localhost:3000")
|
||||||
|
const link = `${baseUrl}/join/${roomId}`
|
||||||
|
return { roomId, link }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const joinRoom = useCallback((roomId: string, displayName?: string) => {
|
||||||
|
roomIdRef.current = roomId
|
||||||
|
setCallState("calling")
|
||||||
|
socketRef.current?.emit("room:join", { roomId, displayName: displayName || "" })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const leaveRoom = useCallback(() => {
|
||||||
|
if (roomIdRef.current) {
|
||||||
|
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
|
||||||
|
}
|
||||||
|
setCallState("ended")
|
||||||
|
cleanupCall()
|
||||||
|
}, [cleanupCall])
|
||||||
|
|
||||||
|
return {
|
||||||
|
callState,
|
||||||
|
isCallActive: callState === "connected" || callState === "connecting" || callState === "ringing" || callState === "calling" || callState === "waiting" || callState === "participant_joined",
|
||||||
|
isIncoming: callState === "ringing" && !!callerInfo,
|
||||||
|
isMuted,
|
||||||
|
isSpeakerOn,
|
||||||
|
callDuration,
|
||||||
|
error,
|
||||||
|
isReady,
|
||||||
|
remoteStream,
|
||||||
|
callerInfo,
|
||||||
|
calleeNumber,
|
||||||
|
participants,
|
||||||
|
createRoom,
|
||||||
|
joinRoom,
|
||||||
|
leaveRoom,
|
||||||
|
makeCall,
|
||||||
|
acceptCall,
|
||||||
|
rejectCall,
|
||||||
|
endCall,
|
||||||
|
toggleMute,
|
||||||
|
toggleSpeaker,
|
||||||
|
lookupUser,
|
||||||
|
formatDuration,
|
||||||
|
tokenLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-27
@@ -1,45 +1,76 @@
|
|||||||
|
import http from "http"
|
||||||
|
|
||||||
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
|
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
|
||||||
|
|
||||||
|
function parseUrl(url: string) {
|
||||||
|
const u = new URL(url)
|
||||||
|
return { hostname: u.hostname, port: parseInt(u.port) || 80, path: u.pathname + u.search }
|
||||||
|
}
|
||||||
|
|
||||||
export async function chatWithAI(message: string, jwtToken: string) {
|
export async function chatWithAI(message: string, jwtToken: string) {
|
||||||
const url = `${AI_SERVICE}/ai/chat`
|
|
||||||
const body = JSON.stringify({ message })
|
const body = JSON.stringify({ message })
|
||||||
|
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const text = await new Promise<string>((resolve, reject) => {
|
||||||
method: "POST",
|
const req = http.request(
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwtToken}` },
|
{ hostname, port, path, method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}`, "Content-Length": Buffer.byteLength(body) } },
|
||||||
body,
|
(res) => {
|
||||||
})
|
let data = ""
|
||||||
|
res.on("data", (chunk) => data += chunk)
|
||||||
const text = await res.text()
|
res.on("end", () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
if (!res.ok) {
|
resolve(data)
|
||||||
throw new Error(`AI error ${res.status}: ${text.substring(0, 200)}`)
|
} else {
|
||||||
|
reject(new Error(`AI error ${res.statusCode}: ${data.substring(0, 200)}`))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
req.on("error", reject)
|
||||||
|
req.write(body)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
|
||||||
const data = JSON.parse(text)
|
const data = JSON.parse(text)
|
||||||
return data.response || ""
|
return data.response || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchJobs() {
|
export async function checkAiServiceStatus() {
|
||||||
|
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/health`)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${AI_SERVICE}/ai/jobs`)
|
await new Promise<void>((resolve, reject) => {
|
||||||
if (!res.ok) return []
|
const req = http.get({ hostname, port, path, timeout: 3000 }, (res) => {
|
||||||
const data = await res.json()
|
let data = ""
|
||||||
|
res.on("data", (chunk) => data += chunk)
|
||||||
|
res.on("end", () => {
|
||||||
|
try { resolve(JSON.parse(data).status === "ok" ? undefined : reject(new Error("not ok"))) } catch { reject(new Error("bad response")) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.on("error", reject)
|
||||||
|
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJobs() {
|
||||||
|
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/jobs`)
|
||||||
|
try {
|
||||||
|
const text = await new Promise<string>((resolve, reject) => {
|
||||||
|
const req = http.get({ hostname, port, path, timeout: 5000 }, (res) => {
|
||||||
|
let data = ""
|
||||||
|
res.on("data", (chunk) => data += chunk)
|
||||||
|
res.on("end", () => resolve(data))
|
||||||
|
})
|
||||||
|
req.on("error", reject)
|
||||||
|
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
|
||||||
|
})
|
||||||
|
const data = JSON.parse(text)
|
||||||
return data.jobs || []
|
return data.jobs || []
|
||||||
} catch {
|
} catch {
|
||||||
console.warn("Failed to fetch AI jobs")
|
console.warn("Failed to fetch AI jobs")
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkAiServiceStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${AI_SERVICE}/health`)
|
|
||||||
if (!res.ok) return false
|
|
||||||
const data = await res.json()
|
|
||||||
return data.status === "ok"
|
|
||||||
} catch {
|
|
||||||
console.warn("Failed to check AI service status")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+24
-2
@@ -17,6 +17,7 @@ export interface SessionUser {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -85,7 +86,7 @@ export async function getUserByUsername(username: string) {
|
|||||||
|
|
||||||
export async function getUserById(id: string) {
|
export async function getUserById(id: string) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
` SELECT u.id, u.username, u.email, u.phone, u.first_name, u.last_name,
|
||||||
u.is_active, u.avatar_url,
|
u.is_active, u.avatar_url,
|
||||||
r.name AS role_name
|
r.name AS role_name
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -185,6 +186,7 @@ export function mapDbUserToSessionUser(
|
|||||||
id: dbUser.id as string,
|
id: dbUser.id as string,
|
||||||
username: dbUser.username as string,
|
username: dbUser.username as string,
|
||||||
email: dbUser.email as string,
|
email: dbUser.email as string,
|
||||||
|
phone: (dbUser.phone as string) || null,
|
||||||
firstName: dbUser.first_name as string,
|
firstName: dbUser.first_name as string,
|
||||||
lastName: dbUser.last_name as string,
|
lastName: dbUser.last_name as string,
|
||||||
role: roleMapping[roleName] || roleName.toLowerCase(),
|
role: roleMapping[roleName] || roleName.toLowerCase(),
|
||||||
@@ -192,6 +194,27 @@ export function mapDbUserToSessionUser(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function encryptPassword(password: string): Promise<string> {
|
||||||
|
const result = await query("SELECT encrypt_password($1) AS encrypted", [password]);
|
||||||
|
return result.rows[0]?.encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptPassword(encrypted: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await query("SELECT decrypt_password($1) AS decrypted", [encrypted]);
|
||||||
|
return result.rows[0]?.decrypted || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSessionContext(userId: string, ip?: string) {
|
||||||
|
await query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
|
||||||
|
if (ip) {
|
||||||
|
await query("SELECT set_config('app.current_ip', $1, true)", [ip]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSession(userId: string, role: string) {
|
export async function createSession(userId: string, role: string) {
|
||||||
const token = await signToken({ userId, role });
|
const token = await signToken({ userId, role });
|
||||||
|
|
||||||
@@ -201,7 +224,6 @@ export async function createSession(userId: string, role: string) {
|
|||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ export const LEAD_SOURCES = [
|
|||||||
|
|
||||||
export const ITEMS_PER_PAGE = 10
|
export const ITEMS_PER_PAGE = 10
|
||||||
|
|
||||||
export const COMPANY_NAME = "Coast IT"
|
export const COMPANY_NAME = "Black Cipher"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createClient, SupabaseClient } from "@supabase/supabase-js"
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""
|
||||||
|
|
||||||
|
let client: SupabaseClient | null = null
|
||||||
|
|
||||||
|
if (supabaseUrl && supabaseAnonKey) {
|
||||||
|
client = createClient(supabaseUrl, supabaseAnonKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSupabase(): SupabaseClient {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.")
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
|
|||||||
|
|
||||||
const publicRoutes = [
|
const publicRoutes = [
|
||||||
"/login",
|
"/login",
|
||||||
|
"/join",
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
"/_next/static",
|
"/_next/static",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
id: u.id,
|
id: u.id,
|
||||||
name: `${u.firstName} ${u.lastName}`,
|
name: `${u.firstName} ${u.lastName}`,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
|
phone: u.phone || "",
|
||||||
role: u.role,
|
role: u.role,
|
||||||
active: true,
|
active: true,
|
||||||
avatar: u.avatar,
|
avatar: u.avatar,
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
|
||||||
|
|
||||||
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||||
|
|
||||||
|
const themeClasses: Record<string, string> = {
|
||||||
|
spidey: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebsiteThemeContextValue {
|
||||||
|
websiteTheme: string
|
||||||
|
setWebsiteTheme: (theme: string) => Promise<void>
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebsiteThemeContext = createContext<WebsiteThemeContextValue | null>(null)
|
||||||
|
|
||||||
|
function applyWebsiteTheme(theme: string) {
|
||||||
|
const prefix = "theme-"
|
||||||
|
const root = document.documentElement
|
||||||
|
const classes = root.className.split(" ").filter((c) => !c.startsWith(prefix))
|
||||||
|
const cls = themeClasses[theme]
|
||||||
|
if (cls) {
|
||||||
|
classes.push(cls)
|
||||||
|
}
|
||||||
|
root.className = classes.join(" ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredTheme(): string | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
return localStorage.getItem(WEBSITE_THEME_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeTheme(theme: string) {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
localStorage.setItem(WEBSITE_THEME_KEY, theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = getStoredTheme()
|
||||||
|
if (stored) {
|
||||||
|
setWebsiteThemeState(stored)
|
||||||
|
applyWebsiteTheme(stored)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetch("/api/settings/website-theme")
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
const theme = data?.websiteTheme || "spidey"
|
||||||
|
setWebsiteThemeState(theme)
|
||||||
|
storeTheme(theme)
|
||||||
|
applyWebsiteTheme(theme)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
applyWebsiteTheme("spidey")
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setWebsiteTheme = useCallback(async (theme: string) => {
|
||||||
|
setWebsiteThemeState(theme)
|
||||||
|
storeTheme(theme)
|
||||||
|
applyWebsiteTheme(theme)
|
||||||
|
try {
|
||||||
|
await fetch("/api/settings/website-theme", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ websiteTheme: theme }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.warn("Failed to persist website theme")
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebsiteThemeContext.Provider value={{ websiteTheme, setWebsiteTheme, loading }}>
|
||||||
|
{children}
|
||||||
|
</WebsiteThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebsiteTheme() {
|
||||||
|
const ctx = useContext(WebsiteThemeContext)
|
||||||
|
if (!ctx) throw new Error("useWebsiteTheme must be used within WebsiteThemeProvider")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
+2
-1
@@ -4,12 +4,13 @@ export type LeadStatus =
|
|||||||
| "pending"
|
| "pending"
|
||||||
| "closed"
|
| "closed"
|
||||||
| "ignored";
|
| "ignored";
|
||||||
export type UserRole = "super_admin" | "admin" | "sales";
|
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
phone?: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user