Files Changed
Security Fixes
File Change
.env.local Placeholder JWT secret (rotate to random value: openssl rand -base64 64)
src/middleware.ts Removed /api/auth/me bypass; API routes return JSON 401 (not 302 redirect)
src/lib/auth.ts sameSite: "lax" → "strict" (create + destroy cookie); SVG name chars are pre-computed (no injection)
src/lib/avatar.ts Added sanitizeName() — strips non-alphanumeric/safe chars, max 50 chars
src/app/api/users/route.ts POST restricted to super_admin; validates role against whitelist ["sales","admin","super_admin","dev"]; validates active defaults to true
src/app/api/users/[id]/route.ts DELETE restricted to super_admin; blocks self-deletion
src/app/api/leads/[id]/route.ts GET: non-admin users filtered by assigned_to; PATCH: ownership check + score validated 0-100 range
src/app/api/leads/route.ts DELETE: ownership + admin check; GET: ownership filter for non-admin users
src/app/api/conversations/[id]/messages/route.ts GET/POST: verify user is a conversation_participant before access
rust-ai/src/main.rs CORS: AllowOrigin::list(["localhost:3006", "127.0.0.1:3006"]) (was Any)
Performance Fixes
File Change
src/app/api/leads/route.ts Added limit (default 50), offset query params; ownership filter in SQL
src/app/api/users/route.ts Added limit (default 50), offset query params
src/app/api/conversations/route.ts Added LIMIT 50
src/app/api/leads/[id]/notes/route.ts Added limit (default 50), offset query params
src/app/api/conversations/[id]/messages/route.ts Added limit (default 100), offset, before query params
src/app/(dashboard)/chats/page.tsx Pruned to 5 cached conversations; useMemo wraps messages/conversation/filteredConversations; otherParticipant moved outside component; added 10MB file size limit; recordingChunksRef cleared on discard
src/app/(dashboard)/leads/[id]/page.tsx Optimistic status update rolls back on fetch failure
database/migrations/003_chat.sql Added composite index idx_messages_conversation_created on (conversation_id, created_at DESC)
Code Quality Fixes
File Change
src/lib/ai.ts Removed dead getInstructions()/updateInstructions() (called nonexistent /ai/instructions)
src/lib/auth.ts bcrypt.hash(password, 12) — kept (acceptable, OWASP-recommended)
src/app/login/page.tsx "Forgot password?" button now shows alert to contact admin (was no-op)
src/components/users/user-form-dialog.tsx Password min 6 → 8 chars
26 silent catch {} blocks across 16 files (see below) Added console.warn("...") context messages
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function DashboardPage() {
|
||||
setStats(data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
console.warn("Failed to fetch dashboard stats")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [] })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,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 {
|
||||
@@ -56,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" })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SystemMonitor() {
|
||||
setRssMB(data.rssMB)
|
||||
setCpuPct(data.cpuPct)
|
||||
} catch {
|
||||
// ignore
|
||||
console.warn("Failed to fetch system stats")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user