diff --git a/browser-use-service/main.py b/browser-use-service/main.py
index 249180b..7994527 100644
--- a/browser-use-service/main.py
+++ b/browser-use-service/main.py
@@ -513,26 +513,27 @@ def is_offer(text: str) -> bool:
# Each run picks 2-4 random queries to vary behavior and reduce detection.
FB_SEARCHES = [
"looking for web developer",
- "need a website designed",
- "need website built South Africa",
"need someone to build my website",
- "need web designer",
- "looking for someone to create website",
- "need ecommerce website built",
- "want to hire web developer",
- "need wordpress website",
- "I need a website 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",
+ "recommend a web developer",
+ "I need a website for my",
+ "need a site for my business",
+ "looking for web designer",
"help me build a website",
"need someone to design my website",
- "looking for web designer near me",
- "need a website for my startup",
+ "anyone know a web developer",
+ "need a web developer for my project",
+ "need website for my startup",
+ "want a website for my company",
+ "need a new website for my",
+ "looking for web developer for my business",
+ "need ecommerce website for my",
+ "looking for affordable web design",
+ "need wordpress website",
+ "who can design a website for my",
+ "looking for someone to create a website",
+ "recommendations for a web designer",
]
VIEWPORTS = [
@@ -1401,26 +1402,38 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
if not ai_succeeded:
- strict_keywords = [
+ web_terms = [
"website", "web design", "web develop", "web dev",
+ "web designer", "web developer",
"build my website", "build a website", "create a website",
- "need web", "looking for web", "new website",
- "landing page", "wordpress",
- "need a website", "my website", "business website",
- "need a designer", "help with my website",
- "redesign", "update my website",
+ "landing page", "wordpress", "ecommerce",
+ "my website", "business website",
+ "site for my", "site for my business",
+ "new website", "redesign my website",
+ "help with my website", "update my website",
+ ]
+ request_terms = [
"looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone",
"need help with", "would like", "build me",
"design my", "make me a", "create my",
+ "looking", "need", "want", "help",
+ "who can", "i need",
+ "recommend", "anyone know", "anyone recommend",
+ "know a", "know any", "recommendation",
+ "suggest", "looking for recommendations",
+ "can anyone", "does anyone",
]
filtered = []
for r in results:
t = r['title'].lower()
- if not any(kw in t for kw in strict_keywords):
+ has_web = any(kw in t for kw in web_terms)
+ has_request = any(kw in t for kw in request_terms)
+ if not has_web or not has_request:
continue
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
- 'affordable web design package', 'web hosting']):
+ 'affordable web design package', 'web hosting',
+ 'i do web design', 'i develop websites']):
continue
filtered.append(r)
return filtered[:10]
diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql
index ea40526..e216a01 100644
--- a/database/migrations/run_all.sql
+++ b/database/migrations/run_all.sql
@@ -43,6 +43,9 @@ BEGIN;
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
\i 011_calendar_events.sql
+\echo '=== Running 012_invites.sql (Invites) ==='
+\i 012_invites.sql
+
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
\i 012_sent_emails.sql
diff --git a/public/uploads/voice/b2ce8bd0-6805-4544-8d34-f7c27cb57833.webm b/public/uploads/voice/b2ce8bd0-6805-4544-8d34-f7c27cb57833.webm
new file mode 100644
index 0000000..c0bd07c
Binary files /dev/null and b/public/uploads/voice/b2ce8bd0-6805-4544-8d34-f7c27cb57833.webm differ
diff --git a/src/app/(dashboard)/chats/page.tsx b/src/app/(dashboard)/chats/page.tsx
index bfc2e1c..ae65747 100644
--- a/src/app/(dashboard)/chats/page.tsx
+++ b/src/app/(dashboard)/chats/page.tsx
@@ -920,7 +920,7 @@ const formatPreviewContent = (content: string) => {
-
toast.info("Voice calling coming soon")}>
+ setIsCallModalOpen(true)}>
toast.info("Video calling coming soon")}>
diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts
new file mode 100644
index 0000000..51ad7be
--- /dev/null
+++ b/src/app/api/ai/chat/route.ts
@@ -0,0 +1,29 @@
+import { NextRequest, NextResponse } from "next/server"
+import { chatWithAI } from "@/lib/ai"
+import { getSessionUser } from "@/lib/auth"
+
+export async function POST(request: NextRequest) {
+ 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 })
+ }
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index e7ad058..5f24e89 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -9,6 +9,7 @@ import {
resetFailedAttempts,
isAccountLocked,
createSession,
+ setSessionContext,
} from "@/lib/auth"
export async function POST(request: NextRequest) {
@@ -106,6 +107,7 @@ export async function POST(request: NextRequest) {
)
await createSession(dbUser.id, dbUser.role_name)
+ await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser)
diff --git a/src/app/api/settings/company/route.ts b/src/app/api/settings/company/route.ts
index 8098107..87aa37c 100644
--- a/src/app/api/settings/company/route.ts
+++ b/src/app/api/settings/company/route.ts
@@ -46,10 +46,11 @@ export async function PATCH(request: NextRequest) {
const body = await request.json()
- await query(
+ const result = await query(
`UPDATE company_settings SET
company_name = $1, company_email = $2, company_phone = $3,
- company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`,
+ company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()
+ WHERE id = (SELECT id FROM company_settings ORDER BY updated_at DESC LIMIT 1)`,
[
body.companyName || "",
body.companyEmail || "",
@@ -60,6 +61,21 @@ export async function PATCH(request: NextRequest) {
],
)
+ if (result.rowCount === 0) {
+ await query(
+ `INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address, updated_by)
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [
+ body.companyName || "",
+ body.companyEmail || "",
+ body.companyPhone || "",
+ body.companyWebsite || "",
+ body.companyAddress || "",
+ user.id,
+ ],
+ )
+ }
+
return NextResponse.json({ success: true })
} catch (error) {
console.error("Company settings PATCH error:", error)
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
index 4f0be95..fd4b2ca 100644
--- a/src/app/api/users/route.ts
+++ b/src/app/api/users/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
-import { hashPassword, getSessionUser } from "@/lib/auth"
+import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) {
@@ -68,12 +68,15 @@ export async function POST(request: NextRequest) {
const lastName = nameParts.slice(1).join(" ") || firstName
const username = email.split("@")[0]
const passwordHash = await hashPassword(password)
+ const passwordEncrypted = await encryptPassword(password)
+
+ await setSessionContext(sessionUser.id)
const result = await query(
- `INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
+ `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, $8)
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 = (
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 2c9542e..c1ebbef 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -231,7 +231,7 @@ export default function LoginPage() {
-
+
Black Cipher
diff --git a/src/components/ai/ai-chat.tsx b/src/components/ai/ai-chat.tsx
index 6adfe9d..14081ea 100644
--- a/src/components/ai/ai-chat.tsx
+++ b/src/components/ai/ai-chat.tsx
@@ -1,7 +1,7 @@
"use client"
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) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
@@ -24,9 +24,26 @@ export function AIChat() {
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
- const [ollamaStatus, setOllamaStatus] = useState(null)
+ const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
const messagesEndRef = useRef(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(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
@@ -56,29 +73,13 @@ export function AIChat() {
},
])
})
+ checkServer()
}, [])
-
-
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
- const checkOllama = async () => {
- try {
- const res = await fetch("/api/ai/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "__ping__" }),
- })
- setOllamaStatus(res.status !== 503)
- } catch {
- setOllamaStatus(false)
- }
- }
-
- useEffect(() => { checkOllama() }, [])
-
const sendMessage = async () => {
const msg = input.trim()
if (!msg || loading) return
@@ -96,8 +97,8 @@ export function AIChat() {
})
if (!res.ok) {
- const data = await res.json()
- throw new Error(data.error || "Failed to get response")
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || `Error ${res.status}`)
}
const data = await res.json()
@@ -121,17 +122,86 @@ export function AIChat() {
}
}
+ const bootOverlay = (
+
+
+
+
Servers booting...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+ const readyOverlay = (
+
+
+
+
+
+
+
Try these commands:
+
+ lists
+ leads
+
+
+
+
+ )
+
+ if (bootState === "booting") return bootOverlay
+
return (
- {ollamaStatus === false && (
-
-
-
Ollama not responding. Start it with ollama serve
-
-
-
-
- )}
+ {bootState === "ready" && readyOverlay}
{messages.map((msg, i) => (
@@ -163,7 +233,10 @@ export function AIChat() {
)}
@@ -192,10 +265,15 @@ export function AIChat() {
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"
>
- {loading ? : }
+ {loading ? (
+
+
+
+
+ ) : }
)
-}
+}
\ No newline at end of file
diff --git a/src/lib/ai.ts b/src/lib/ai.ts
index f9573fc..91cee9c 100644
--- a/src/lib/ai.ts
+++ b/src/lib/ai.ts
@@ -1,41 +1,76 @@
+import http from "http"
+
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) {
- const res = await fetch(`${AI_SERVICE}/ai/chat`, {
- method: "POST",
- headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
- body: JSON.stringify({ message }),
+ const body = JSON.stringify({ message })
+ const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
+
+ const text = await new Promise
((resolve, reject) => {
+ const req = http.request(
+ { hostname, port, path, method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}`, "Content-Length": Buffer.byteLength(body) } },
+ (res) => {
+ let data = ""
+ res.on("data", (chunk) => data += chunk)
+ res.on("end", () => {
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
+ resolve(data)
+ } else {
+ reject(new Error(`AI error ${res.statusCode}: ${data.substring(0, 200)}`))
+ }
+ })
+ }
+ )
+ req.on("error", reject)
+ req.write(body)
+ req.end()
})
- if (!res.ok) {
- const text = await res.text()
- throw new Error(`AI service error (${res.status}): ${text}`)
- }
-
- const data = await res.json()
+ const data = JSON.parse(text)
return data.response || ""
}
-export async function fetchJobs() {
+export async function checkAiServiceStatus() {
+ const { hostname, port, path } = parseUrl(`${AI_SERVICE}/health`)
try {
- const res = await fetch(`${AI_SERVICE}/ai/jobs`)
- if (!res.ok) return []
- const data = await res.json()
+ await new Promise((resolve, reject) => {
+ const req = http.get({ hostname, port, path, timeout: 3000 }, (res) => {
+ 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((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 || []
} catch {
console.warn("Failed to fetch AI jobs")
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
- }
-}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index cc58756..8794412 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -17,6 +17,7 @@ export interface SessionUser {
id: string;
username: string;
email: string;
+ phone: string | null;
firstName: string;
lastName: string;
role: string;
@@ -85,7 +86,7 @@ export async function getUserByUsername(username: string) {
export async function getUserById(id: string) {
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,
r.name AS role_name
FROM users u
@@ -185,6 +186,7 @@ export function mapDbUserToSessionUser(
id: dbUser.id as string,
username: dbUser.username as string,
email: dbUser.email as string,
+ phone: (dbUser.phone as string) || null,
firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(),
@@ -201,7 +203,6 @@ export async function createSession(userId: string, role: string) {
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
- maxAge: 60 * 60 * 24, // 24 hours
});
return token;
@@ -218,6 +219,27 @@ export async function destroySession() {
});
}
+export async function encryptPassword(password: string): Promise {
+ const result = await query("SELECT encrypt_password($1) AS encrypted", [password]);
+ return result.rows[0]?.encrypted;
+}
+
+export async function decryptPassword(encrypted: string): Promise {
+ 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 getSessionUser(): Promise {
try {
const cookieStore = await cookies();
diff --git a/src/types/index.ts b/src/types/index.ts
index 83030e9..d5677d9 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -4,12 +4,13 @@ export type LeadStatus =
| "pending"
| "closed"
| "ignored";
-export type UserRole = "super_admin" | "admin" | "sales";
+export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
+ phone?: string;
role: UserRole;
active: boolean;
avatar: string;