Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
This commit is contained in:
+36
-23
@@ -513,26 +513,27 @@ def is_offer(text: str) -> bool:
|
|||||||
# Each run picks 2-4 random queries to vary behavior and reduce detection.
|
# Each run picks 2-4 random queries to vary behavior and reduce detection.
|
||||||
FB_SEARCHES = [
|
FB_SEARCHES = [
|
||||||
"looking for web developer",
|
"looking for web developer",
|
||||||
"need a website designed",
|
|
||||||
"need website built South Africa",
|
|
||||||
"need someone to build my website",
|
"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",
|
"need website for my small business",
|
||||||
"who can build me a website",
|
"who can build me a website",
|
||||||
"want to create a website for my business",
|
"recommend a web developer",
|
||||||
"looking for affordable web design",
|
"I need a website for my",
|
||||||
"need a web developer South Africa",
|
"need a site for my business",
|
||||||
|
"looking for web designer",
|
||||||
"help me build a website",
|
"help me build a website",
|
||||||
"need someone to design my website",
|
"need someone to design my website",
|
||||||
"looking for web designer near me",
|
"anyone know a web developer",
|
||||||
"need a website for my startup",
|
"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 = [
|
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)
|
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
|
||||||
|
|
||||||
if not ai_succeeded:
|
if not ai_succeeded:
|
||||||
strict_keywords = [
|
web_terms = [
|
||||||
"website", "web design", "web develop", "web dev",
|
"website", "web design", "web develop", "web dev",
|
||||||
|
"web designer", "web developer",
|
||||||
"build my website", "build a website", "create a website",
|
"build my website", "build a website", "create a website",
|
||||||
"need web", "looking for web", "new website",
|
"landing page", "wordpress", "ecommerce",
|
||||||
"landing page", "wordpress",
|
"my website", "business website",
|
||||||
"need a website", "my website", "business website",
|
"site for my", "site for my business",
|
||||||
"need a designer", "help with my website",
|
"new website", "redesign my website",
|
||||||
"redesign", "update my website",
|
"help with my website", "update my website",
|
||||||
|
]
|
||||||
|
request_terms = [
|
||||||
"looking for", "need a", "need an", "looking to",
|
"looking for", "need a", "need an", "looking to",
|
||||||
"need someone", "hire a", "want someone",
|
"need someone", "hire a", "want someone",
|
||||||
"need help with", "would like", "build me",
|
"need help with", "would like", "build me",
|
||||||
"design my", "make me a", "create my",
|
"design my", "make me a", "create my",
|
||||||
|
"looking", "need", "want", "help",
|
||||||
|
"who can", "i need",
|
||||||
|
"recommend", "anyone know", "anyone recommend",
|
||||||
|
"know a", "know any", "recommendation",
|
||||||
|
"suggest", "looking for recommendations",
|
||||||
|
"can anyone", "does anyone",
|
||||||
]
|
]
|
||||||
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):
|
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
|
continue
|
||||||
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
|
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
|
continue
|
||||||
filtered.append(r)
|
filtered.append(r)
|
||||||
return filtered[:10]
|
return filtered[:10]
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ BEGIN;
|
|||||||
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
|
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
|
||||||
\i 011_calendar_events.sql
|
\i 011_calendar_events.sql
|
||||||
|
|
||||||
|
\echo '=== Running 012_invites.sql (Invites) ==='
|
||||||
|
\i 012_invites.sql
|
||||||
|
|
||||||
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
|
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
|
||||||
\i 012_sent_emails.sql
|
\i 012_sent_emails.sql
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -920,7 +920,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</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")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
await query(
|
const result = await query(
|
||||||
`UPDATE company_settings SET
|
`UPDATE company_settings SET
|
||||||
company_name = $1, company_email = $2, company_phone = $3,
|
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.companyName || "",
|
||||||
body.companyEmail || "",
|
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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Company settings PATCH error:", error)
|
console.error("Company settings PATCH error:", error)
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -68,12 +68,15 @@ export async function POST(request: NextRequest) {
|
|||||||
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)
|
||||||
|
|
||||||
|
await setSessionContext(sessionUser.id)
|
||||||
|
|
||||||
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 = (
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export default function LoginPage() {
|
|||||||
<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 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"}}>
|
<div className="bc-logo mb-8" style={{"--theme-primary":"#1BB0CE"} as React.CSSProperties}>
|
||||||
Black <span className="accent">Cipher</span>
|
Black <span className="accent">Cipher</span>
|
||||||
</div>
|
</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]">
|
||||||
|
|||||||
+111
-33
@@ -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,9 +24,26 @@ 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("/api/ai/jobs")
|
fetch("/api/ai/jobs")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -56,29 +73,13 @@ export function AIChat() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
checkServer()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [messages])
|
}, [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 sendMessage = async () => {
|
||||||
const msg = input.trim()
|
const msg = input.trim()
|
||||||
if (!msg || loading) return
|
if (!msg || loading) return
|
||||||
@@ -96,8 +97,8 @@ export function AIChat() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error(data.error || "Failed to get response")
|
throw new Error(data.error || `Error ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -121,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) => (
|
||||||
@@ -163,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>
|
||||||
)}
|
)}
|
||||||
@@ -192,10 +265,15 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+61
-26
@@ -1,41 +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 res = await fetch(`${AI_SERVICE}/ai/chat`, {
|
const body = JSON.stringify({ message })
|
||||||
method: "POST",
|
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
|
||||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
|
|
||||||
body: JSON.stringify({ message }),
|
const text = await new Promise<string>((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 data = JSON.parse(text)
|
||||||
const text = await res.text()
|
|
||||||
throw new Error(`AI service error (${res.status}): ${text}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
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(),
|
||||||
@@ -201,7 +203,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;
|
||||||
@@ -218,6 +219,27 @@ export async function destroySession() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 getSessionUser(): Promise<SessionUser | null> {
|
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|||||||
+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