This commit is contained in:
2026-06-23 10:01:52 +02:00
853 changed files with 422644 additions and 1054 deletions
+73 -11
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useRef, useCallback, useEffect } from "react"
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -90,6 +90,9 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
)
}
const MAX_CACHED_CONVERSATIONS = 5
const otherParticipant = (conv: any) => conv.otherUser
export default function ChatsPage() {
const { theme } = useTheme()
const { user } = useUser()
@@ -129,6 +132,8 @@ export default function ChatsPage() {
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const recordingChunksRef = useRef<Blob[]>([])
const resizeStartRef = useRef({ x: 0, width: 0 })
const blobUrlsRef = useRef<string[]>([])
const conversationOrderRef = useRef<string[]>([])
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
@@ -141,12 +146,12 @@ export default function ChatsPage() {
if (!user) return null
const messages = conversationMessages.get(activeChat || "") || []
const conversation = conversations.find((c) => c.id === activeChat)
const otherParticipant = (conv: any) => conv.otherUser
const filteredConversations = searchQuery.trim()
? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
: conversations
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const filteredConversations = useMemo(() => {
if (!searchQuery.trim()) return conversations
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
}, [conversations, searchQuery])
// Fetch conversations from API
useEffect(() => {
@@ -161,7 +166,7 @@ export default function ChatsPage() {
if (convs.length > 0) setActiveChat(convs[0].id)
}
} catch {
// ignore
console.warn("Failed to fetch conversations in chats page")
} finally {
setLoadingChats(false)
}
@@ -172,6 +177,10 @@ export default function ChatsPage() {
// Fetch messages when active chat changes, and mark as read
useEffect(() => {
if (!activeChat) return
conversationOrderRef.current = [
activeChat,
...conversationOrderRef.current.filter((id) => id !== activeChat),
]
const fetchMsgs = async () => {
try {
const res = await fetch(`/api/conversations/${activeChat}/messages`)
@@ -180,6 +189,30 @@ export default function ChatsPage() {
setConversationMessages((prev) => {
const next = new Map(prev)
next.set(activeChat, data.messages || [])
const recent = conversationOrderRef.current.slice(0, MAX_CACHED_CONVERSATIONS)
const recentSet = new Set(recent)
for (const key of next.keys()) {
if (!recentSet.has(key)) next.delete(key)
}
const validMsgIds = new Set<string>()
for (const msgs of next.values()) {
for (const m of msgs) validMsgIds.add(m.id)
}
setVoiceMessages((v) => {
const n = new Map(v)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
return n
})
setMessageAttachments((a) => {
const n = new Map(a)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
return n
})
setReplyMap((r) => {
const n = new Map(r)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
return n
})
return next
})
}
@@ -206,7 +239,7 @@ export default function ChatsPage() {
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
}
} catch { /* ignore */ }
} catch { console.warn("Failed to search users in chats page") }
setSearchingUsers(false)
}, 300)
return () => clearTimeout(timer)
@@ -222,6 +255,14 @@ export default function ChatsPage() {
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
// revoke blob URLs on unmount
useEffect(() => {
return () => {
blobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
blobUrlsRef.current = []
}
}, [])
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
@@ -254,8 +295,16 @@ export default function ChatsPage() {
setTimeout(() => autoResizeTextarea(), 0)
}
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
if (oversized.length > 0) {
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
if (e.target) e.target.value = ""
return
}
setAttachments((prev) => [...prev, ...files])
if (e.target) e.target.value = ""
}
@@ -319,6 +368,7 @@ export default function ChatsPage() {
})
}
} catch {
console.warn("Failed to send message in chats page")
toast.error("Failed to send message")
}
}
@@ -341,7 +391,11 @@ export default function ChatsPage() {
return
}
const fileAttachments = attachments.length > 0
? attachments.map((f) => ({ name: f.name, type: f.type, url: URL.createObjectURL(f) }))
? attachments.map((f) => {
const url = URL.createObjectURL(f)
blobUrlsRef.current.push(url)
return { name: f.name, type: f.type, url }
})
: undefined
if (replyingTo) {
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
@@ -356,6 +410,10 @@ export default function ChatsPage() {
}
const handleDeleteMessage = (messageId: string) => {
const files = messageAttachments.get(messageId)
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
const voice = voiceMessages.get(messageId)
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
setConversationMessages((prev) => {
const next = new Map(prev)
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
@@ -383,6 +441,7 @@ export default function ChatsPage() {
recorder.onstop = () => {
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
const url = URL.createObjectURL(blob)
blobUrlsRef.current.push(url)
addMessageToChat("", { url, duration: recordingDurationRef.current })
stream.getTracks().forEach((t) => t.stop())
setRecordingDuration(0)
@@ -392,6 +451,7 @@ export default function ChatsPage() {
setIsRecording(true)
setIsPaused(false)
} catch {
console.warn("Failed to start recording in chats page")
toast.error("Microphone access denied")
}
}
@@ -425,6 +485,7 @@ export default function ChatsPage() {
setIsPaused(false)
setRecordingDuration(0)
recordingDurationRef.current = 0
recordingChunksRef.current = []
}
useEffect(() => {
@@ -532,7 +593,7 @@ export default function ChatsPage() {
setSearchQuery("")
}
}
} catch { /* ignore */ }
} catch { console.warn("Failed to create conversation in chats page") }
}}
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
>
@@ -911,6 +972,7 @@ export default function ChatsPage() {
toast.success("Message forwarded")
}
} catch {
console.warn("Failed to forward message in chats page")
toast.error("Failed to forward message")
}
}}
+1 -1
View File
@@ -38,7 +38,7 @@ export default function DashboardPage() {
setStats(data)
}
} catch {
// ignore
console.warn("Failed to fetch dashboard stats")
}
}
+4 -1
View File
@@ -3,6 +3,7 @@
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
import { ErrorBoundary } from "@/components/shared/error-boundary"
import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
@@ -29,7 +30,9 @@ export default function DashboardLayout({
return (
<UserProvider>
<NotificationProvider>
<DashboardContent>{children}</DashboardContent>
<ErrorBoundary>
<DashboardContent>{children}</DashboardContent>
</ErrorBoundary>
</NotificationProvider>
</UserProvider>
)
+23 -8
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import Link from "next/link"
import { motion } from "framer-motion"
import { use } from "react"
@@ -26,22 +26,26 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
const [leadNotes, setLeadNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
const fetchNotes = useCallback(async () => {
const notesRes = await fetch(`/api/leads/${id}/notes`)
if (notesRes.ok) setLeadNotes(await notesRes.json())
}, [id])
useEffect(() => {
async function fetchData() {
try {
const [leadRes, notesRes] = await Promise.all([
const [leadRes] = await Promise.all([
fetch(`/api/leads/${id}`),
fetch(`/api/leads/${id}/notes`),
fetchNotes(),
])
if (leadRes.ok) setLead(await leadRes.json())
if (notesRes.ok) setLeadNotes(await notesRes.json())
} catch {
// ignore
console.warn("Failed to fetch lead details")
}
setLoading(false)
}
fetchData()
}, [id])
}, [id, fetchNotes])
if (loading) {
return (
@@ -89,8 +93,19 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
>
<Select
value={lead.status}
onValueChange={(v) => {
onValueChange={async (v) => {
const previousStatus = lead.status
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
try {
const res = await fetch(`/api/leads/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: v }),
})
if (!res.ok) setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
} catch {
setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
}
}}
>
<SelectTrigger className="h-9 w-[140px]">
@@ -125,7 +140,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<NoteForm leadId={lead.id} />
<NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
</motion.div>
<motion.div
+29 -31
View File
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useForm } from "react-hook-form"
@@ -26,9 +26,7 @@ import {
} from "@/components/ui/select"
import { Card, CardContent } from "@/components/ui/card"
import { ArrowLeft } from "lucide-react"
import { Lead, LeadStatus } from "@/types"
import { leads } from "@/data/leads"
import { users } from "@/data/users"
import { User } from "@/types"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
@@ -49,6 +47,14 @@ export default function CreateLeadPage() {
const router = useRouter()
const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false)
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
}, [])
const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema),
@@ -64,35 +70,27 @@ export default function CreateLeadPage() {
},
})
function onSubmit(values: LeadFormValues) {
async function onSubmit(values: LeadFormValues) {
setSaving(true)
const now = new Date().toISOString()
const assignedUserId: string | null = values.assignedUserId === "none" ? null : (values.assignedUserId ?? null)
const assignedUser = assignedUserId ? users.find((u) => u.id === assignedUserId) ?? null : null
const newLead: Lead = {
id: `lead-${String(leads.length + 1).padStart(3, "0")}`,
companyName: values.companyName,
contactName: values.contactName,
email: values.email,
phone: values.phone ?? "",
source: values.source ?? "",
description: values.description ?? "",
status: values.status as LeadStatus,
assignedUserId,
assignedUser,
createdAt: now,
updatedAt: now,
try {
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
}
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to create lead")
const data = await res.json()
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
router.push("/leads")
} catch (e) {
console.error("Create lead error:", e)
} finally {
setSaving(false)
}
leads.unshift(newLead)
addNotification(
"lead_created",
"New Lead Created",
`${newLead.companyName}${newLead.contactName}`,
`/leads/${newLead.id}`
)
router.push("/leads")
}
return (
+1
View File
@@ -39,6 +39,7 @@ export default function ProfilePage() {
toast.error("Failed to save avatar")
}
} catch {
console.warn("Failed to save avatar")
toast.error("Failed to save avatar")
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function UsersPage() {
setUsers(data.users || [])
}
} catch {
// ignore
console.warn("Failed to fetch users in users page")
} finally {
setLoading(false)
}
+5 -1
View File
@@ -16,7 +16,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
const response = await chatWithAI(message, user.id, user.role)
// Forward the JWT from the session cookie to the Rust backend
const sessionCookie = request.cookies.get("session")?.value
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 })
const response = await chatWithAI(message, sessionCookie)
return NextResponse.json({ response })
} catch (error) {
+1
View File
@@ -14,6 +14,7 @@ export async function GET() {
const jobs = await fetchJobs()
return NextResponse.json({ jobs })
} catch {
console.warn("Failed to fetch AI jobs in API route")
return NextResponse.json({ jobs: [] })
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable. Please ensure PostgreSQL is running and configured." },
{ error: "Authentication service unavailable." },
{ status: 503 }
)
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(
_request: NextRequest,
@@ -12,18 +13,40 @@ export async function GET(
const { id } = await params
const { searchParams } = new URL(_request.url)
const limit = parseInt(searchParams.get("limit") || "100", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const before = searchParams.get("before") || ""
// Verify user is a participant
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id]
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
let msgSql = `SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
u.first_name || ' ' || u.last_name AS sender_name,
u.email AS sender_email,
u.avatar_url AS sender_avatar_url
FROM messages m
JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
const msgParams: any[] = [id]
if (before) {
msgSql += ` AND m.created_at < $2`
msgParams.push(before)
}
msgSql += ` ORDER BY m.created_at ASC`
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
msgParams.push(limit, offset)
const [msgResult, otherReadResult] = await Promise.all([
query(
`SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
u.first_name || ' ' || u.last_name AS sender_name,
u.email AS sender_email,
u.avatar_url AS sender_avatar_url
FROM messages m
JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL
ORDER BY m.created_at ASC`,
[id],
),
query(msgSql, msgParams),
query(
`SELECT last_read_at FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
@@ -40,7 +63,7 @@ export async function GET(
conversationId: id,
senderId: row.sender_id,
senderName: row.sender_name,
senderAvatar: row.sender_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.sender_name)}&background=1d4ed8&color=fff&size=128`,
senderAvatar: avatarSvgUrl(row.sender_name),
content: row.content,
timestamp: formatTime(new Date(row.created_at)),
createdAt: row.created_at,
@@ -65,8 +88,17 @@ export async function POST(
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { content } = await request.json()
// Verify user is a participant
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id]
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
const { content } = await request.json()
if (!content?.trim()) {
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
}
+5 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() {
try {
@@ -26,7 +27,8 @@ export async function GET() {
WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
)
ORDER BY c.updated_at DESC`,
ORDER BY c.updated_at DESC
LIMIT 50`,
[user.id],
)
@@ -37,7 +39,7 @@ export async function GET() {
id: row.other_user_id,
name: row.other_user_name,
email: row.other_user_email,
avatar: row.other_user_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.other_user_name)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(row.other_user_name),
},
lastMessage: row.last_message || "",
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "",
@@ -103,7 +105,7 @@ export async function POST(request: NextRequest) {
id: other.id,
name: other.name,
email: other.email,
avatar: other.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(other.name)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(other.name),
},
lastMessage: "",
lastMessageTime: "",
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function getPeriodDateRange(period: string): { start: Date; end: Date } {
const end = new Date()
@@ -158,7 +159,7 @@ export async function GET(request: NextRequest) {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
+8 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
@@ -32,6 +33,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const result = await query(
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
@@ -39,8 +43,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
FROM customer_notes cn
JOIN users u ON u.id = cn.author_id
WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL
ORDER BY cn.created_at DESC`,
[id]
ORDER BY cn.created_at DESC
LIMIT $2 OFFSET $3`,
[id, limit, offset]
)
const notes = result.rows.map((r: any) => ({
@@ -48,7 +53,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
leadId: id,
userId: r.user_id,
authorName: `${r.first_name} ${r.last_name}`,
authorAvatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
authorAvatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
note: r.content,
createdAt: r.created_at,
updatedAt: r.updated_at,
+88 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function stageToStatus(name: string): string {
switch (name) {
@@ -22,6 +23,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
@@ -31,8 +33,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.id = $1 AND l.deleted_at IS NULL`,
[id]
WHERE l.id = $1 AND l.deleted_at IS NULL
AND ($2 = true OR l.assigned_to = $3)`,
[id, isAdmin, user.id]
)
if (result.rows.length === 0) {
@@ -54,7 +57,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
@@ -66,3 +69,85 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 })
}
}
function statusToStageName(status: string): string {
switch (status) {
case "open": return "New"
case "contacted": return "Contacted"
case "pending": return "Qualified"
case "closed": return "Closed Won"
case "ignored": return "Closed Lost"
default: return "New"
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Verify access
const accessCheck = await query(
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
AND ($2 = true OR assigned_to = $3)`,
[id, isAdmin, user.id]
)
if (accessCheck.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.companyName !== undefined) { fields.push(`company_name = $${idx++}`); values.push(body.companyName) }
if (body.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) }
if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) }
if (body.phone !== undefined) { fields.push(`phone = $${idx++}`); values.push(body.phone) }
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
if (body.assignedUserId !== undefined) {
fields.push(`assigned_to = $${idx++}`)
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
}
if (body.status !== undefined) {
const stageName = statusToStageName(body.status)
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
if (stageResult.rows.length > 0) {
fields.push(`stage_id = $${idx++}`)
values.push(stageResult.rows[0].id)
}
}
if (body.score !== undefined) {
const score = Number(body.score)
if (!isFinite(score) || score < 0 || score > 100) {
return NextResponse.json({ error: "Score must be a number between 0 and 100" }, { status: 400 })
}
fields.push(`score = $${idx++}`)
values.push(score)
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
const sql = `UPDATE leads SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING id`
const result = await query(sql, values)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
return NextResponse.json({ success: true, id: result.rows[0].id })
} catch (error) {
console.error("Lead PATCH error:", error)
return NextResponse.json({ error: "Failed to update lead" }, { status: 500 })
}
}
+77 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
function stageToStatus(name: string): string {
switch (name) {
@@ -38,6 +39,9 @@ export async function GET(request: NextRequest) {
const search = searchParams.get("search") || ""
const status = searchParams.get("status") || "all"
const period = searchParams.get("period") || "all"
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const isAdmin = user.role === "admin" || user.role === "super_admin"
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
@@ -66,7 +70,16 @@ export async function GET(request: NextRequest) {
paramIdx++
}
if (!isAdmin) {
sql += ` AND l.assigned_to = $${paramIdx}`
params.push(user.id)
paramIdx++
}
sql += ` ORDER BY l.created_at DESC`
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
params.push(limit, offset)
paramIdx += 2
const result = await query(sql, params)
@@ -86,7 +99,7 @@ export async function GET(request: NextRequest) {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
@@ -103,3 +116,66 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const stageResult = await query(
"SELECT id FROM lead_stages WHERE name = $1",
[body.status === "open" ? "New" : "Contacted"]
)
const stageId = stageResult.rows[0]?.id || 1
const result = await query(
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING id`,
[
body.companyName,
body.contactName,
body.email,
body.phone || null,
body.description || null,
body.source || null,
stageId,
body.assignedUserId === "none" ? null : body.assignedUserId,
]
)
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error) {
console.error("Leads POST error:", error)
return NextResponse.json({ error: "Failed to create lead" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
const result = await query(
"UPDATE leads SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3) RETURNING id",
[id, isAdmin, user.id]
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Leads DELETE error:", error)
return NextResponse.json({ error: "Failed to delete lead" }, { status: 500 })
}
}
+6
View File
@@ -6,6 +6,9 @@ export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const result = await query(`SELECT * FROM company_settings ORDER BY updated_at DESC LIMIT 1`)
const row = result.rows[0]
@@ -37,6 +40,9 @@ export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const body = await request.json()
+4
View File
@@ -1,10 +1,14 @@
import { NextResponse } from "next/server"
import os from "os"
import { getSessionUser } from "@/lib/auth"
let prevCpu = process.cpuUsage()
let prevTime = Date.now()
export async function GET() {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const now = Date.now()
const elapsed = now - prevTime
const currentCpu = process.cpuUsage()
+7
View File
@@ -8,9 +8,16 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super admins can delete users" }, { status: 403 })
}
const { id } = await params
if (id === sessionUser.id) {
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
}
await query(
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
[id]
+22 -4
View File
@@ -1,9 +1,17 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET() {
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
const result = await query(
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active AS active, u.created_at, u.avatar_url,
@@ -12,7 +20,9 @@ export async function GET() {
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.deleted_at IS NULL
ORDER BY u.created_at DESC`
ORDER BY u.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
)
const users = result.rows.map((row: any) => ({
id: row.id,
@@ -20,7 +30,7 @@ export async function GET() {
email: row.email,
role: row.role.toLowerCase(),
active: row.active,
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${row.first_name}+${row.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(`${row.first_name} ${row.last_name}`),
createdAt: row.created_at,
}))
return NextResponse.json({ users }, { status: 200 })
@@ -36,12 +46,20 @@ export async function POST(request: NextRequest) {
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super admins can create users" }, { status: 403 })
}
const { name, email, password, role, active } = await request.json()
if (!name || !email || !password || !role) {
return NextResponse.json({ error: "Name, email, password, and role are required" }, { status: 400 })
}
const validRoles = ["sales", "admin", "super_admin", "dev"]
if (!validRoles.includes(role)) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
}
const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName
@@ -52,7 +70,7 @@ export async function POST(request: NextRequest) {
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active, sessionUser.id]
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
)
const roleId = (
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) {
try {
@@ -29,7 +30,7 @@ export async function GET(request: NextRequest) {
id: row.id,
name: row.name,
email: row.email,
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.name)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarSvgUrl(row.name),
}))
return NextResponse.json({ users })
+1 -1
View File
@@ -340,7 +340,7 @@ export default function LoginPage() {
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
Password
</label>
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline">
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}>
Forgot password?
</button>
</div>
+16 -37
View File
@@ -1,8 +1,19 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { useState, useRef, useEffect, Fragment } from "react"
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
const parts = text.split(urlRegex)
return parts.map((part, i) => {
if (part.startsWith("http://") || part.startsWith("https://")) {
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a>
}
return <Fragment key={i}>{part}</Fragment>
})
}
interface ChatMessage {
role: "user" | "assistant"
content: string
@@ -21,11 +32,11 @@ export function AIChat() {
.then((r) => r.json())
.then((data) => {
if (data.jobs?.length) {
const jobNames = data.jobs.map((j: { job_title: string }) => j.job_title).join(", ")
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages([
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help you with tips for targeting: ${jobNames}. What would you like to know?`,
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => ` ${n}`).join("\n")}\n\nWhat would you like to know?`,
},
])
} else {
@@ -45,41 +56,9 @@ export function AIChat() {
},
])
})
fetch("/api/ai/jobs")
.then((r) => r.json())
.then((data) => {
if (data.jobs) {
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages([
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `${n}`).join("\n")}\n\nWhat would you like to know?`,
},
])
}
})
}, [])
useEffect(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
.then((data) => {
if (data.jobs?.length) {
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
setMessages((prev) =>
prev.length === 1 && prev[0].role === "assistant"
? [
{
role: "assistant",
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `${n}`).join("\n")}\n\nWhat would you like to know?`,
},
]
: prev,
)
}
})
}, [])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
@@ -169,7 +148,7 @@ export function AIChat() {
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
}`}
>
{msg.content}
{linkifyText(msg.content)}
</div>
{msg.role === "user" && (
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
@@ -37,7 +37,7 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
setChartData(data.leadsPerMonth || [])
}
} catch {
// ignore
console.warn("Failed to fetch year data in leads per month chart")
}
}
const thisYear = new Date().getFullYear()
+1 -1
View File
@@ -18,7 +18,7 @@ export function SystemMonitor() {
setRssMB(data.rssMB)
setCpuPct(data.cpuPct)
} catch {
// ignore
console.warn("Failed to fetch system stats")
}
}
+42 -18
View File
@@ -32,7 +32,6 @@ import {
} from "@/components/ui/select"
import { Lead, LeadStatus, User } from "@/types"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider"
const leadFormSchema = z.object({
@@ -52,11 +51,21 @@ interface LeadFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
lead?: Lead | null
onSuccess?: () => void
}
export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps) {
export function LeadFormDialog({ open, onOpenChange, lead, onSuccess }: LeadFormDialogProps) {
const isEditing = !!lead
const { addNotification } = useNotifications()
const [users, setUsers] = useState<User[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
}, [])
const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema),
@@ -98,22 +107,37 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
}
}, [lead, form])
function onSubmit(values: LeadFormValues) {
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId,
async function onSubmit(values: LeadFormValues) {
setSaving(true)
try {
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId,
}
if (isEditing && lead) {
const res = await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to update")
addNotification("lead_status_changed", "Lead Updated", `${values.companyName}`, "#")
} else {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to create")
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, "#")
}
onOpenChange(false)
onSuccess?.()
} catch (e) {
console.error("Lead save error:", e)
} finally {
setSaving(false)
}
if (!isEditing) {
const now = new Date().toISOString()
addNotification(
"lead_created",
"New Lead Created",
`${values.companyName}${values.contactName}`,
"#"
)
}
console.log(payload)
onOpenChange(false)
}
return (
@@ -277,7 +301,7 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit">{isEditing ? "Save Changes" : "Create Lead"}</Button>
<Button type="submit" disabled={saving}>{saving ? "Saving..." : isEditing ? "Save Changes" : "Create Lead"}</Button>
</DialogFooter>
</form>
</Form>
+38 -25
View File
@@ -6,12 +6,15 @@ import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Send } from "lucide-react"
import { useUser } from "@/providers/user-provider"
interface NoteFormProps {
leadId: string
onNoteAdded?: () => void
}
export function NoteForm({ leadId }: NoteFormProps) {
export function NoteForm({ leadId, onNoteAdded }: NoteFormProps) {
const { user } = useUser()
const [note, setNote] = useState("")
const [submitting, setSubmitting] = useState(false)
@@ -25,43 +28,53 @@ export function NoteForm({ leadId }: NoteFormProps) {
body: JSON.stringify({ content: note }),
})
setNote("")
window.location.reload()
onNoteAdded?.()
} catch {
// ignore
console.warn("Failed to add note")
}
setSubmitting(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
return (
<Card>
<CardHeader>
<CardTitle>Add Note</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-4">
<Avatar className="h-10 w-10 shrink-0">
<AvatarImage src="https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=64" />
<AvatarFallback>SC</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-3">
<Textarea
placeholder="Write a note about this lead..."
value={note}
onChange={(e) => setNote(e.target.value)}
className="min-h-[100px] resize-none"
/>
<div className="flex justify-end">
<Button
onClick={handleSubmit}
disabled={!note.trim() || submitting}
className="gap-2"
>
<Send className="h-4 w-4" />
{submitting ? "Adding..." : "Add Note"}
</Button>
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div className="flex gap-4">
<Avatar className="h-10 w-10 shrink-0">
<AvatarImage src={user?.avatar || undefined} />
<AvatarFallback>{user?.name?.charAt(0) || "U"}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-3">
<Textarea
placeholder="Write a note about this lead..."
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleKeyDown}
className="min-h-[100px] resize-none"
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={!note.trim() || submitting}
className="gap-2"
>
<Send className="h-4 w-4" />
{submitting ? "Adding..." : "Add Note"}
</Button>
</div>
</div>
</div>
</div>
</form>
</CardContent>
</Card>
)
@@ -35,7 +35,7 @@ export function CompanySettingsForm() {
setData(json)
}
} catch {
// ignore
console.warn("Failed to load company settings")
} finally {
setLoading(false)
}
@@ -57,6 +57,7 @@ export function CompanySettingsForm() {
toast.error("Failed to save company settings")
}
} catch {
console.warn("Failed to save company settings")
toast.error("Failed to save company settings")
} finally {
setSaving(false)
@@ -98,11 +99,13 @@ export function CompanySettingsForm() {
<Input id="company-address" disabled={loading} value={data.companyAddress} onChange={(e) => update("companyAddress", e.target.value)} />
</div>
</div>
<div className="flex justify-end pt-4">
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={loading || saving}>
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
@@ -45,7 +45,7 @@ export function NotificationSettings() {
setPrefs(data)
}
} catch {
// ignore
console.warn("Failed to load notification preferences")
} finally {
setLoading(false)
}
@@ -67,6 +67,7 @@ export function NotificationSettings() {
toast.error("Failed to save preferences")
}
} catch {
console.warn("Failed to save notification preferences")
toast.error("Failed to save preferences")
} finally {
setSaving(false)
@@ -105,11 +106,13 @@ export function NotificationSettings() {
/>
</div>
))}
<div className="flex justify-end pt-4">
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
@@ -37,7 +37,7 @@ export function UserPreferencesForm() {
setPrefs(json)
}
} catch {
// ignore
console.warn("Failed to load user preferences")
} finally {
setLoading(false)
}
@@ -59,6 +59,7 @@ export function UserPreferencesForm() {
toast.error("Failed to save preferences")
}
} catch {
console.warn("Failed to save user preferences")
toast.error("Failed to save preferences")
} finally {
setSaving(false)
@@ -117,11 +118,13 @@ export function UserPreferencesForm() {
</Select>
</div>
</div>
<div className="flex justify-end pt-4">
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={loading || saving}>
{saving ? "Saving..." : "Save Preferences"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
+50
View File
@@ -0,0 +1,50 @@
"use client"
import { Component, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: { componentStack?: string }) {
console.error("ErrorBoundary caught:", error, info.componentStack)
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<h2 className="text-xl font-bold">Something went wrong</h2>
<p className="text-muted-foreground text-sm max-w-md text-center">
{this.state.error?.message || "An unexpected error occurred"}
</p>
<Button variant="outline" onClick={() => {
this.setState({ hasError: false, error: undefined })
window.location.reload()
}}>
Reload Page
</Button>
</div>
)
}
return this.props.children
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ import { toast } from "sonner";
const createSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
password: z.string().min(8, "Password must be at least 8 characters"),
role: z.string().min(1, "Role is required"),
active: z.boolean(),
});
+1
View File
@@ -32,6 +32,7 @@ export function UsersTable({ data, loading, onUserDeleted }: UsersTableProps) {
toast.success("User deleted")
onUserDeleted?.()
} catch {
console.warn("Failed to delete user")
toast.error("Failed to delete user")
}
}
+5 -23
View File
@@ -1,10 +1,10 @@
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
export async function chatWithAI(message: string, userId: string, userRole: string) {
export async function chatWithAI(message: string, jwtToken: string) {
const res = await fetch(`${AI_SERVICE}/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, user_id: userId, user_role: userRole }),
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message }),
})
if (!res.ok) {
@@ -23,30 +23,11 @@ export async function fetchJobs() {
const data = await res.json()
return data.jobs || []
} catch {
console.warn("Failed to fetch AI jobs")
return []
}
}
export async function getInstructions() {
try {
const res = await fetch(`${AI_SERVICE}/ai/instructions`)
if (!res.ok) return null
const data = await res.json()
return data.success ? data.instructions : null
} catch {
return null
}
}
export async function updateInstructions(entry: string, content?: string) {
const res = await fetch(`${AI_SERVICE}/ai/instructions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry, content }),
})
return res.ok
}
export async function checkAiServiceStatus() {
try {
const res = await fetch(`${AI_SERVICE}/health`)
@@ -54,6 +35,7 @@ export async function checkAiServiceStatus() {
const data = await res.json()
return data.status === "ok"
} catch {
console.warn("Failed to check AI service status")
return false
}
}
+9 -9
View File
@@ -3,9 +3,11 @@ import bcrypt from "bcryptjs";
import { query } from "@/lib/db";
import { cookies } from "next/headers";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production",
);
const RAW_SECRET = process.env.JWT_SECRET;
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5;
@@ -174,7 +176,7 @@ export function mapDbUserToSessionUser(
SUPER_ADMIN: "super_admin",
ADMIN: "admin",
SALES_USER: "sales",
DEVELOPER: "sales",
DEVELOPER: "dev",
};
const avatarUrl = dbUser.avatar_url as string | null
@@ -186,9 +188,7 @@ export function mapDbUserToSessionUser(
firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(),
avatar: avatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(
`${dbUser.first_name}+${dbUser.last_name}`,
)}&background=1d4ed8&color=fff&size=128`,
avatar: avatarUrl || (() => { const f = ((dbUser.first_name as string)?.[0] || '').toUpperCase(); const l = ((dbUser.last_name as string)?.[0] || '').toUpperCase(); return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><rect width="128" height="128" fill="#1d4ed8"/><text x="64" y="80" font-size="48" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${f}${l}</text></svg>`)}` })(),
};
}
@@ -199,7 +199,7 @@ export async function createSession(userId: string, role: string) {
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});
@@ -212,7 +212,7 @@ export async function destroySession() {
cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "strict",
path: "/",
maxAge: 0,
});
+26
View File
@@ -0,0 +1,26 @@
function sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9\s\-'.-]/g, "").slice(0, 50)
}
export function getInitials(name: string): string {
return sanitizeName(name)
.split(/\s+/)
.filter(Boolean)
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
export function avatarSvgUrl(name: string, bg = "1d4ed8", size = 128): string {
const initials = getInitials(name)
const fontSize = Math.round(size * 0.375)
const y = Math.round(size * 0.625)
return `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" fill="#${bg}"/><text x="${Math.round(size / 2)}" y="${y}" font-size="${fontSize}" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${initials}</text></svg>`
)}`
}
export function getAvatarUrl(name: string, avatarUrl: string | null | undefined, bg = "1d4ed8", size = 128): string {
return avatarUrl || avatarSvgUrl(name, bg, size)
}
+6 -1
View File
@@ -1,10 +1,15 @@
import { Pool } from "pg"
const dbUrl = process.env.DATABASE_URL
if (!dbUrl) {
throw new Error("DATABASE_URL environment variable is required")
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
connectionString: dbUrl,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
})
pool.on("error", (err) => {
+11 -8
View File
@@ -2,15 +2,16 @@ import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { jwtVerify } from "jose"
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
)
const RAW_SECRET = process.env.JWT_SECRET
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required")
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [
"/login",
"/api/auth/login",
"/api/auth/logout",
"/api/system",
"/_next/static",
"/_next/image",
"/favicon.ico",
@@ -25,10 +26,6 @@ export async function middleware(request: NextRequest) {
(route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route)
)
if (pathname === "/api/auth/me") {
return NextResponse.next()
}
if (isPublic) {
return NextResponse.next()
}
@@ -36,6 +33,9 @@ export async function middleware(request: NextRequest) {
const sessionCookie = request.cookies.get("session")?.value
if (!sessionCookie) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
@@ -45,6 +45,9 @@ export async function middleware(request: NextRequest) {
await jwtVerify(sessionCookie, JWT_SECRET)
return NextResponse.next()
} catch {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
+6 -6
View File
@@ -30,7 +30,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
setNotifications(data.notifications)
}
} catch {
// ignore
console.warn("Failed to fetch notifications in notification provider")
} finally {
setLoading(false)
}
@@ -51,7 +51,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
setUnreadChatCount(total)
}
} catch {
// ignore
console.warn("Failed to fetch unread conversations in notification provider")
}
}
fetchUnread()
@@ -83,7 +83,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
body: JSON.stringify({ type, title, description, link }),
})
} catch {
// ignore
console.warn("Failed to add notification in notification provider")
}
},
[]
@@ -97,7 +97,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try {
await fetch(`/api/notifications/${id}`, { method: "PATCH" })
} catch {
// ignore
console.warn("Failed to mark notification as read in notification provider")
}
}, [])
@@ -106,7 +106,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try {
await fetch("/api/notifications", { method: "PATCH" })
} catch {
// ignore
console.warn("Failed to mark all notifications as read in notification provider")
}
}, [])
@@ -116,7 +116,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
try {
await fetch(`/api/notifications/${id}`, { method: "DELETE" })
} catch {
// ignore
console.warn("Failed to dismiss notification in notification provider")
}
}, [])
+2 -1
View File
@@ -39,6 +39,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
setUser(null)
}
} catch {
console.warn("Failed to fetch user in user provider")
setUser(null)
} finally {
setLoading(false)
@@ -53,7 +54,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
try {
await fetch("/api/auth/logout", { method: "POST" })
} catch {
// Proceed with client-side logout even if API fails
console.warn("Failed to logout in user provider")
}
setUser(null)
router.push("/login")