Current state

This commit is contained in:
Chariah
2026-06-26 14:31:38 +02:00
commit 7a76841309
982 changed files with 451988 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { chatWithAI } from "@/lib/ai"
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (!["sales", "admin", "super_admin"].includes(user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { message } = await request.json()
if (!message || typeof message !== "string") {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
// 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) {
console.error("AI chat error:", error)
return NextResponse.json({ error: "AI service unavailable" }, { status: 503 })
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { fetchJobs } from "@/lib/ai"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (!["sales", "admin", "super_admin"].includes(user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const jobs = await fetchJobs()
return NextResponse.json({ jobs })
} catch {
console.warn("Failed to fetch AI jobs in API route")
return NextResponse.json({ jobs: [] })
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get("session")?.value
if (!token) {
return NextResponse.json({ error: "No session" }, { status: 401 })
}
return NextResponse.json({ token })
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server"
import {
comparePassword,
getUserByEmail,
getUserByUsername,
mapDbUserToSessionUser,
recordLoginAttempt,
incrementFailedAttempts,
resetFailedAttempts,
isAccountLocked,
createSession,
} from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { email, username, password } = await request.json()
const credential = email || username
if (!credential || !password) {
return NextResponse.json(
{ error: "Email/Username and password are required." },
{ status: 400 }
)
}
if (credential.trim().length === 0 || password.trim().length === 0) {
return NextResponse.json(
{ error: "Credentials cannot be empty." },
{ status: 400 }
)
}
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
const userAgent = request.headers.get("user-agent") || null
// Try to find user by email first, then by username
let dbUser =
email || credential.includes("@")
? await getUserByEmail(credential)
: null
if (!dbUser) {
dbUser = await getUserByUsername(credential)
}
if (!dbUser) {
await recordLoginAttempt(
null,
credential,
ipAddress,
userAgent,
false,
"User not found"
)
return NextResponse.json(
{ error: "Invalid email/username or password." },
{ status: 401 }
)
}
const lockStatus = await isAccountLocked(dbUser)
if (lockStatus.locked) {
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
false,
lockStatus.reason
)
return NextResponse.json(
{ error: lockStatus.reason },
{ status: 423 }
)
}
const valid = await comparePassword(password, dbUser.password_hash)
if (!valid) {
await incrementFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
false,
"Invalid password"
)
return NextResponse.json(
{ error: "Invalid email/username or password." },
{ status: 401 }
)
}
await resetFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
true
)
await createSession(dbUser.id, dbUser.role_name)
const user = mapDbUserToSessionUser(dbUser)
return NextResponse.json({ user }, { status: 200 })
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable." },
{ status: 503 }
)
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server"
import { destroySession } from "@/lib/auth"
export async function POST() {
try {
await destroySession()
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("Logout error:", error)
return NextResponse.json(
{ error: "Logout failed." },
{ status: 500 }
)
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
return NextResponse.json({ user }, { status: 200 })
} catch (error) {
console.error("Auth me error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable." },
{ status: 503 }
)
}
}
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server"
import {
getSessionUser,
decryptPassword,
setSessionContext,
} from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 })
}
const { userId } = await request.json()
if (!userId) {
return NextResponse.json({ error: "userId is required." }, { status: 400 })
}
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
await setSessionContext(sessionUser.id, ipAddress)
const result = await query(
`SELECT id, username, email, first_name, last_name, password_encrypted
FROM users WHERE id = $1 AND deleted_at IS NULL`,
[userId]
)
const user = result.rows[0]
if (!user) {
return NextResponse.json({ error: "User not found." }, { status: 404 })
}
if (!user.password_encrypted) {
return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 })
}
const plaintextPassword = await decryptPassword(user.password_encrypted)
if (!plaintextPassword) {
return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 })
}
return NextResponse.json({
user: {
id: user.id,
username: user.username,
email: user.email,
name: `${user.first_name} ${user.last_name}`,
},
password: plaintextPassword,
}, { status: 200 })
} catch (error) {
console.error("Password recovery error:", error)
return NextResponse.json({ error: "Recovery service unavailable." }, { status: 503 })
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser, setSessionContext } from "@/lib/auth"
export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden. Only admins can update bug reports." }, { status: 403 })
}
const { id } = await routeParams
const { status, assigned_to, resolution_notes } = await request.json()
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
await setSessionContext(sessionUser.id, ipAddress)
const validStatuses = ["open", "in_progress", "resolved", "closed"]
if (status && !validStatuses.includes(status)) {
return NextResponse.json({ error: "Invalid status." }, { status: 400 })
}
const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id])
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Bug report not found." }, { status: 404 })
}
const updates: string[] = []
const values: unknown[] = []
let paramIndex = 1
if (status !== undefined) {
updates.push(`status = $${paramIndex++}`)
values.push(status)
}
if (assigned_to !== undefined) {
updates.push(`assigned_to = $${paramIndex++}`)
values.push(assigned_to === "null" ? null : assigned_to)
}
if (resolution_notes !== undefined) {
updates.push(`resolution_notes = $${paramIndex++}`)
values.push(resolution_notes)
}
if (updates.length === 0) {
return NextResponse.json({ error: "No fields to update." }, { status: 400 })
}
updates.push(`updated_at = NOW()`)
values.push(id)
await query(
`UPDATE bug_reports SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
values
)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("Error updating bug report:", error)
return NextResponse.json({ error: "Failed to update bug report." }, { status: 500 })
}
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
const { title, description, severity, page_url, screenshot_url } = await request.json()
if (!title || !description) {
return NextResponse.json({ error: "Title and description are required." }, { status: 400 })
}
const validSeverities = ["low", "medium", "high", "critical"]
if (severity && !validSeverities.includes(severity)) {
return NextResponse.json({ error: "Invalid severity." }, { status: 400 })
}
const result = await query(
`INSERT INTO bug_reports (reported_by, title, description, severity, page_url, screenshot_url)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at`,
[sessionUser.id, title, description, severity || "medium", page_url || null, screenshot_url || null]
)
return NextResponse.json({
success: true,
id: result.rows[0].id,
created_at: result.rows[0].created_at,
}, { status: 201 })
} catch (error) {
console.error("Error creating bug report:", error)
return NextResponse.json({ error: "Failed to submit bug report." }, { status: 500 })
}
}
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden. Only admins can view bug reports." }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const severity = searchParams.get("severity")
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url,
br.screenshot_url, br.status, br.resolution_notes,
br.created_at, br.updated_at,
reporter.id AS reporter_id,
reporter.first_name AS reporter_first_name,
reporter.last_name AS reporter_last_name,
reporter.email AS reporter_email,
assignee.id AS assignee_id,
assignee.first_name AS assignee_first_name,
assignee.last_name AS assignee_last_name
FROM bug_reports br
JOIN users reporter ON reporter.id = br.reported_by
LEFT JOIN users assignee ON assignee.id = br.assigned_to`
const params: unknown[] = []
const conditions: string[] = []
if (status) {
conditions.push(`br.status = $${params.length + 1}`)
params.push(status)
}
if (severity) {
conditions.push(`br.severity = $${params.length + 1}`)
params.push(severity)
}
if (conditions.length > 0) {
sql += " WHERE " + conditions.join(" AND ")
}
sql += " ORDER BY br.created_at DESC LIMIT $" + (params.length + 1) + " OFFSET $" + (params.length + 2)
params.push(limit, offset)
const result = await query(sql, params)
const reports = result.rows.map((row: any) => ({
id: row.id,
title: row.title,
description: row.description,
severity: row.severity,
page_url: row.page_url,
screenshot_url: row.screenshot_url,
status: row.status,
resolution_notes: row.resolution_notes,
created_at: row.created_at,
updated_at: row.updated_at,
reporter: {
id: row.reporter_id,
name: `${row.reporter_first_name} ${row.reporter_last_name}`,
email: row.reporter_email,
},
assignee: row.assignee_id ? {
id: row.assignee_id,
name: `${row.assignee_first_name} ${row.assignee_last_name}`,
} : null,
}))
return NextResponse.json({ reports }, { status: 200 })
} catch (error) {
console.error("Error fetching bug reports:", error)
return NextResponse.json({ error: "Failed to fetch bug reports." }, { status: 500 })
}
}
@@ -0,0 +1,162 @@
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,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getSessionUser()
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") || "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(msgSql, msgParams),
query(
`SELECT last_read_at FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
[id, user.id],
),
])
const otherLastReadAt = otherReadResult.rows[0]?.last_read_at
? new Date(otherReadResult.rows[0].last_read_at).getTime()
: 0
const messages = msgResult.rows.map((row: any) => ({
id: row.id,
conversationId: id,
senderId: row.sender_id,
senderName: row.sender_name,
senderAvatar: avatarSvgUrl(row.sender_name),
content: row.content,
timestamp: formatTime(new Date(row.created_at)),
createdAt: row.created_at,
read: row.sender_id === user.id
? new Date(row.created_at).getTime() <= otherLastReadAt
: true,
}))
return NextResponse.json({ messages })
} catch (error) {
console.error("Messages error:", error)
return NextResponse.json({ error: "Failed to load messages" }, { status: 500 })
}
}
export async function POST(
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
// 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 })
}
const result = await query(
`INSERT INTO messages (conversation_id, sender_id, content)
VALUES ($1, $2, $3)
RETURNING id, created_at`,
[id, user.id, content.trim()],
)
await query(
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
[id],
)
const msg = result.rows[0]
const senderName = `${user.firstName} ${user.lastName}`
const otherResult = await query(
`SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
[id, user.id],
)
for (const row of otherResult.rows) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, 'chat_message', 'New Message', $2, '/chats', $3, 'conversation')`,
[row.user_id, `${senderName} sent a message`, id],
)
}
return NextResponse.json({
message: {
id: msg.id,
conversationId: id,
senderId: user.id,
senderName,
senderAvatar: user.avatar,
content: content.trim(),
timestamp: formatTime(new Date(msg.created_at)),
createdAt: msg.created_at,
read: false,
},
})
} catch (error) {
console.error("Send message error:", error)
return NextResponse.json({ error: "Failed to send message" }, { status: 500 })
}
}
function formatTime(date: Date): string {
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(
_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
await query(
`UPDATE conversation_participants
SET last_read_at = NOW()
WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id],
)
await query(
`UPDATE notifications SET is_read = TRUE
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
[user.id, id],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Mark read error:", error)
return NextResponse.json({ error: "Failed to mark as read" }, { status: 500 })
}
}
+131
View File
@@ -0,0 +1,131 @@
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 {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT
c.id,
c.updated_at,
cp_me.last_read_at,
u.id AS other_user_id,
u.first_name || ' ' || u.last_name AS other_user_name,
u.email AS other_user_email,
u.avatar_url AS other_user_avatar_url,
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
FROM conversations c
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
JOIN conversation_participants cp ON cp.conversation_id = c.id
JOIN users u ON u.id = cp.user_id AND u.id != $1
WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
)
ORDER BY c.updated_at DESC
LIMIT 50`,
[user.id],
)
const conversations = result.rows.map((row: any) => ({
id: row.id,
updatedAt: row.updated_at,
otherUser: {
id: row.other_user_id,
name: row.other_user_name,
email: row.other_user_email,
avatar: avatarSvgUrl(row.other_user_name),
},
lastMessage: row.last_message || "",
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "",
unread: parseInt(row.unread) || 0,
}))
return NextResponse.json({ conversations })
} catch (error) {
console.error("Conversations error:", error)
return NextResponse.json({ error: "Failed to load conversations" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { userId } = await request.json()
if (!userId) {
return NextResponse.json({ error: "userId is required" }, { status: 400 })
}
if (userId === user.id) {
return NextResponse.json({ error: "Cannot start a conversation with yourself" }, { status: 400 })
}
const existing = await query(
`SELECT c.id FROM conversations c
JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = $1
JOIN conversation_participants cp2 ON cp2.conversation_id = c.id AND cp2.user_id = $2
LIMIT 1`,
[user.id, userId],
)
if (existing.rows.length > 0) {
return NextResponse.json({ conversationId: existing.rows[0].id })
}
const convResult = await query(
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
)
const conversationId = convResult.rows[0].id
await query(
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
[conversationId, user.id, userId],
)
const otherUser = await query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users WHERE id = $1`,
[userId],
)
const other = otherUser.rows[0]
return NextResponse.json({
conversation: {
id: conversationId,
updatedAt: new Date().toISOString(),
otherUser: {
id: other.id,
name: other.name,
email: other.email,
avatar: avatarSvgUrl(other.name),
},
lastMessage: "",
lastMessageTime: "",
unread: 0,
},
})
} catch (error) {
console.error("Create conversation error:", error)
return NextResponse.json({ error: "Failed to create conversation" }, { status: 500 })
}
}
function timeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return "now"
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return date.toLocaleDateString()
}
+213
View File
@@ -0,0 +1,213 @@
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()
let start: Date
switch (period) {
case "7days":
start = new Date(end); start.setDate(start.getDate() - 7); break
case "30days":
start = new Date(end); start.setDate(start.getDate() - 30); break
case "12months":
start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
case "6months":
default:
start = new Date(end); start.setMonth(start.getMonth() - 6); break
}
return { start, end }
}
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
const end = new Date(currentStart)
const diff = end.getTime() - currentStart.getTime()
const start = new Date(end.getTime() - diff)
return { start, end }
}
const periodLabels: Record<string, string> = {
"7days": "Last 7 days",
"30days": "Last 30 days",
"6months": "Last 6 months",
"12months": "Last 12 months",
}
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
case "Contacted": return "contacted"
case "Qualified":
case "Interested":
case "Demo Scheduled":
case "Negotiation": return "pending"
case "Closed Won": return "closed"
case "Closed Lost": return "ignored"
default: return "open"
}
}
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
const result = await query(
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
l.notes, l.assigned_to, l.score,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
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.deleted_at IS NULL
AND l.created_at >= $1 AND l.created_at <= $2
${isAdmin ? "" : "AND l.assigned_to = $3"}
ORDER BY l.created_at DESC`,
isAdmin
? [start.toISOString(), end.toISOString()]
: [start.toISOString(), end.toISOString(), userId]
)
return result.rows.map((r: any) => ({
...r,
status: stageToStatus(r.stage_name),
}))
}
function countStatuses(leads: any[]) {
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
leads.forEach((l: any) => {
const s = l.status as keyof typeof counts
if (s in counts) counts[s]++
})
return counts
}
function buildMonthlyBreakdown(leads: any[], period: string) {
const { start, end } = getPeriodDateRange(period)
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
const current = new Date(start)
const isMonthly = period === "6months" || period === "12months"
while (current <= end) {
const label = isMonthly
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
const ps = new Date(current)
const pe = isMonthly
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
const inPeriod = leads.filter((l: any) => {
const d = new Date(l.created_at)
return d >= ps && d <= pe
})
const counts = countStatuses(inPeriod)
result.push({ label, total: inPeriod.length, ...counts })
if (isMonthly) current.setMonth(current.getMonth() + 1)
else current.setDate(current.getDate() + 1)
}
return result
}
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
const pct = Math.round(((current - previous) / previous) * 100)
return { pct: Math.abs(pct), up: pct >= 0 }
}
export async function GET(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 period = searchParams.get("period") || "6months"
const yearParam = searchParams.get("year")
let start: Date, end: Date, prevRange: { start: Date; end: Date }
if (yearParam) {
const y = parseInt(yearParam)
start = new Date(y, 0, 1)
end = new Date(y, 11, 31, 23, 59, 59)
prevRange = { start: new Date(y - 1, 0, 1), end: new Date(y - 1, 11, 31, 23, 59, 59) }
} else {
const r = getPeriodDateRange(period)
start = r.start; end = r.end
prevRange = getPreviousPeriodRange(period, start)
}
const [currentLeads, prevLeads] = await Promise.all([
fetchLeadsInRange(start, end, user.id, isAdmin),
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
])
const currentCounts = countStatuses(currentLeads)
const prevCounts = countStatuses(prevLeads)
const totalLeads = currentLeads.length
const closedLeads = currentCounts.closed
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
const mappedLeads = currentLeads.map((r: any) => ({
id: r.id,
companyName: r.company_name || "",
contactName: r.contact_name,
email: r.email || "",
phone: r.phone || "",
source: "",
description: r.notes || "",
status: r.status,
assignedUserId: r.assigned_to,
assignedUser: r.assigned_to ? {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
}))
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
const trends = {
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
openLeads: computeTrend(currentCounts.open, prevCounts.open),
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
conversionRate: computeTrend(conversionRate,
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
}
const stats = {
totalLeads,
openLeads: currentCounts.open,
contactedLeads: currentCounts.contacted,
pendingLeads: currentCounts.pending,
closedLeads,
ignoredLeads: currentCounts.ignored,
conversionRate,
monthlyBreakdown,
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
trends,
recentLeads: mappedLeads.slice(0, 10),
statusDistribution: [
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
],
periodLabel: periodLabels[period] ?? "Selected period",
}
return NextResponse.json(stats)
} catch (error) {
console.error("Dashboard API error:", error)
return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 })
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import crypto from "crypto"
export async function POST(request: NextRequest) {
try {
const { phone, token: clientToken } = await request.json()
if (!phone) {
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
}
const token = clientToken || crypto.randomBytes(24).toString("hex")
await query(
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
[token, phone],
)
const origin = request.headers.get("origin") || "http://localhost:3000"
const inviteUrl = `${origin}/join/${token}`
return NextResponse.json({ token, inviteUrl })
} catch {
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
}
}
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
const result = await query(
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
AND (assigned_to = $2 OR EXISTS (
SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $2 AND r.name IN ('ADMIN', 'SUPER_ADMIN')
))`,
[leadId, userId]
)
return result.rows.length > 0
}
export async function POST(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
if (!await checkLeadAccess(id, user.id)) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const { content } = await request.json()
if (!content?.trim()) {
return NextResponse.json({ error: "Content is required" }, { status: 400 })
}
await query(
`INSERT INTO customer_notes (customer_id, author_id, content)
VALUES ($1, $2, $3)`,
[id, user.id, content.trim()]
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Create note error:", error)
return NextResponse.json({ error: "Failed to create note" }, { status: 500 })
}
}
export async function GET(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
if (!await checkLeadAccess(id, user.id)) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
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,
u.id AS user_id, u.first_name, u.last_name, u.avatar_url
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
LIMIT $2 OFFSET $3`,
[id, limit, offset]
)
const notes = result.rows.map((r: any) => ({
id: r.id,
leadId: id,
userId: r.user_id,
authorName: `${r.first_name} ${r.last_name}`,
authorAvatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
note: r.content,
createdAt: r.created_at,
updatedAt: r.updated_at,
}))
return NextResponse.json(notes)
} catch (error) {
console.error("Lead notes API error:", error)
return NextResponse.json({ error: "Failed to load notes" }, { status: 500 })
}
}
+158
View File
@@ -0,0 +1,158 @@
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) {
case "New": return "open"
case "Contacted": return "contacted"
case "Qualified":
case "Interested":
case "Demo Scheduled":
case "Negotiation": return "pending"
case "Closed Won": return "closed"
case "Closed Lost": return "ignored"
default: return "open"
}
}
export async function GET(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"
const result = await query(
`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,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
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
AND ($2 = true OR l.assigned_to = $3)`,
[id, isAdmin, user.id]
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const r = result.rows[0]
const lead = {
id: r.id,
companyName: r.company_name || "",
contactName: r.contact_name,
email: r.email || "",
phone: r.phone || "",
source: "",
description: r.notes || "",
status: stageToStatus(r.stage_name),
assignedUserId: r.assigned_to,
assignedUser: r.assigned_to ? {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
}
return NextResponse.json(lead)
} catch (error) {
console.error("Lead detail API error:", error)
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) {
const isAdmin = user.role === "admin" || user.role === "super_admin"
if (!isAdmin) {
// non-admin cannot reassign
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
}
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 })
}
}
+190
View File
@@ -0,0 +1,190 @@
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) {
case "New": return "open"
case "Contacted": return "contacted"
case "Qualified":
case "Interested":
case "Demo Scheduled":
case "Negotiation": return "pending"
case "Closed Won": return "closed"
case "Closed Lost": return "ignored"
default: return "open"
}
}
function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
if (period === "all") return null
const end = new Date()
let start: Date
switch (period) {
case "7days": start = new Date(end); start.setDate(start.getDate() - 7); break
case "30days": start = new Date(end); start.setDate(start.getDate() - 30); break
case "12months": start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
case "6months": default: start = new Date(end); start.setMonth(start.getMonth() - 6); break
}
return { start, end }
}
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
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,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
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.deleted_at IS NULL`
const params: any[] = []
let paramIdx = 1
if (period !== "all") {
const range = getPeriodDateRange(period)
if (range) {
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
params.push(range.start.toISOString(), range.end.toISOString())
paramIdx += 2
}
}
if (search) {
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
params.push(`%${search}%`)
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)
let leads = result.rows.map((r: any) => {
const s = stageToStatus(r.stage_name)
return {
id: r.id,
companyName: r.company_name || "",
contactName: r.contact_name,
email: r.email || "",
phone: r.phone || "",
source: "",
description: r.notes || "",
status: s,
assignedUserId: r.assigned_to,
assignedUser: r.assigned_to ? {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
}
})
if (status !== "all") {
leads = leads.filter((l: any) => l.status === status)
}
return NextResponse.json(leads)
} catch (error) {
console.error("Leads API error:", error)
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 isAdmin = user.role === "admin" || user.role === "super_admin"
// Non-admin users can only assign leads to themselves; admin/super_admin can assign to anyone
let assignedUserId = body.assignedUserId
if (!isAdmin) {
assignedUserId = user.id
} else if (assignedUserId === "none" || !assignedUserId) {
assignedUserId = null
}
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,
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 })
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
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 result = await query(
`UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 RETURNING id`,
[id, user.id],
)
if (result.rowCount === 0) {
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Notification PATCH error:", error)
return NextResponse.json({ error: "Failed to mark notification as read" }, { status: 500 })
}
}
export async function DELETE(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 result = await query(
`DELETE FROM notifications WHERE id = $1 AND user_id = $2 RETURNING id`,
[id, user.id],
)
if (result.rowCount === 0) {
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Notification DELETE error:", error)
return NextResponse.json({ error: "Failed to dismiss notification" }, { status: 500 })
}
}
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT lead_assigned, lead_status, note_added, daily_digest, weekly_report
FROM notification_preferences
WHERE user_id = $1`,
[user.id],
)
if (result.rowCount === 0) {
return NextResponse.json({
leadAssigned: true,
leadStatus: true,
noteAdded: false,
dailyDigest: false,
weeklyReport: true,
})
}
const r = result.rows[0]
return NextResponse.json({
leadAssigned: r.lead_assigned,
leadStatus: r.lead_status,
noteAdded: r.note_added,
dailyDigest: r.daily_digest,
weeklyReport: r.weekly_report,
})
} catch (error) {
console.error("Preferences GET error:", error)
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
await query(
`INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (user_id)
DO UPDATE SET lead_assigned = $2, lead_status = $3, note_added = $4,
daily_digest = $5, weekly_report = $6, updated_at = NOW()`,
[
user.id,
body.leadAssigned ?? true,
body.leadStatus ?? true,
body.noteAdded ?? false,
body.dailyDigest ?? false,
body.weeklyReport ?? true,
],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Preferences PATCH error:", error)
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
}
}
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT id, type, title, description, link, is_read, created_at, context_id, context_type
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50`,
[user.id],
)
const notifications = result.rows.map((r: any) => ({
id: r.id,
type: r.type,
title: r.title,
description: r.description,
link: r.link,
read: r.is_read,
timestamp: r.created_at,
contextId: r.context_id,
contextType: r.context_type,
}))
const unreadResult = await query(
`SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`,
[user.id],
)
return NextResponse.json({
notifications,
unreadCount: parseInt(unreadResult.rows[0].count, 10),
})
} catch (error) {
console.error("Notifications GET error:", error)
return NextResponse.json({ error: "Failed to load notifications" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { type, title, description, link, userId } = await request.json()
const isAdmin = user.role === "admin" || user.role === "super_admin"
const targetUserId = (userId && isAdmin) ? userId : user.id
const result = await query(
`INSERT INTO notifications (user_id, type, title, description, link)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, type, title, description, link, is_read, created_at`,
[targetUserId, type, title, description || null, link || null],
)
const notif = result.rows[0]
return NextResponse.json({
id: notif.id,
type: notif.type,
title: notif.title,
description: notif.description,
link: notif.link,
read: notif.is_read,
timestamp: notif.created_at,
}, { status: 201 })
} catch (error) {
console.error("Notifications POST error:", error)
return NextResponse.json({ error: "Failed to create notification" }, { status: 500 })
}
}
export async function PATCH() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
await query(
`UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`,
[user.id],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Notifications PATCH error:", error)
return NextResponse.json({ error: "Failed to mark all as read" }, { status: 500 })
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
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]
if (!row) {
return NextResponse.json({
companyName: "Coastal IT Solutions",
companyEmail: "info@coastalit.com",
companyPhone: "(555) 123-4567",
companyWebsite: "https://coastalit.com",
companyAddress: "123 Business Ave, Suite 100, San Francisco, CA 94105",
})
}
return NextResponse.json({
companyName: row.company_name || "",
companyEmail: row.company_email || "",
companyPhone: row.company_phone || "",
companyWebsite: row.company_website || "",
companyAddress: row.company_address || "",
})
} catch (error) {
console.error("Company settings GET error:", error)
return NextResponse.json({ error: "Failed to load company settings" }, { status: 500 })
}
}
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()
await query(
`UPDATE company_settings SET
company_name = $1, company_email = $2, company_phone = $3,
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`,
[
body.companyName || "",
body.companyEmail || "",
body.companyPhone || "",
body.companyWebsite || "",
body.companyAddress || "",
user.id,
],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Company settings PATCH error:", error)
return NextResponse.json({ error: "Failed to save company settings" }, { status: 500 })
}
}
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
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 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { id } = await params
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.isActive !== undefined) {
fields.push(`is_active = $${idx++}`)
values.push(body.isActive)
}
if (body.label !== undefined) {
fields.push(`label = $${idx++}`)
values.push(body.label.trim())
}
if (body.profilePath !== undefined) {
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
values.push(body.profilePath.trim())
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
idx++
}
if (body.unflag === true) {
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
await query(
`UPDATE facebook_accounts SET ${fields.join(", ")} WHERE id = $${idx}`,
values
)
const updated = await query(
`SELECT id, label, profile_path, is_active, flagged, flagged_reason,
consecutive_failures, updated_at
FROM facebook_accounts WHERE id = $1`,
[id]
)
return NextResponse.json(updated.rows[0] || { success: true })
} catch (error) {
console.error("Facebook accounts PATCH error:", error)
return NextResponse.json({ error: "Failed to update Facebook account" }, { status: 500 })
}
}
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const accounts = await query(
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
fa.last_scrape_at, fa.last_success_at, fa.last_error_at,
fa.last_error_message, fa.consecutive_failures,
fa.flagged, fa.flagged_at, fa.flagged_reason,
fa.created_at, fa.updated_at,
COALESCE(sl.leads_found, 0) AS last_leads_found,
sl.success AS last_success,
sl.detected_flag AS last_detected_flag
FROM facebook_accounts fa
LEFT JOIN LATERAL (
SELECT leads_found, success, detected_flag
FROM facebook_scrape_logs
WHERE account_id = fa.id
ORDER BY created_at DESC
LIMIT 1
) sl ON TRUE
ORDER BY fa.created_at ASC`
)
return NextResponse.json(accounts.rows)
} catch (error) {
console.error("Facebook accounts GET error:", error)
return NextResponse.json({ error: "Failed to load Facebook accounts" }, { status: 500 })
}
}
export async function POST(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 { label, profilePath } = await request.json()
if (!label?.trim() || !profilePath?.trim()) {
return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 })
}
const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
const result = await query(
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)
VALUES ($1, $2, $3)
RETURNING id, label, profile_path, is_active, created_at`,
[label.trim(), profilePath.trim(), cookieFile]
)
return NextResponse.json(result.rows[0], { status: 201 })
} catch (error) {
console.error("Facebook accounts POST error:", error)
return NextResponse.json({ error: "Failed to create Facebook account" }, { status: 500 })
}
}
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(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 { searchParams } = new URL(request.url)
const accountId = searchParams.get("accountId")
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
let sql = `SELECT sl.id, sl.account_id, fa.label AS account_label,
sl.started_at, sl.completed_at, sl.success,
sl.leads_found, sl.error_message, sl.detected_flag,
sl.created_at
FROM facebook_scrape_logs sl
JOIN facebook_accounts fa ON fa.id = sl.account_id`
const params: any[] = []
let paramIdx = 1
if (accountId) {
sql += ` WHERE sl.account_id = $${paramIdx++}`
params.push(accountId)
}
sql += ` ORDER BY sl.created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`
params.push(limit, offset)
const result = await query(sql, params)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Facebook scrape logs GET error:", error)
return NextResponse.json({ error: "Failed to load scrape logs" }, { status: 500 })
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT timezone, date_format, items_per_page FROM user_preferences WHERE user_id = $1`,
[user.id],
)
if (result.rowCount === 0) {
return NextResponse.json({
timezone: "america-los_angeles",
dateFormat: "mdy",
itemsPerPage: 20,
})
}
const r = result.rows[0]
return NextResponse.json({
timezone: r.timezone,
dateFormat: r.date_format,
itemsPerPage: r.items_per_page,
})
} catch (error) {
console.error("Preferences GET error:", error)
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
await query(
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id)
DO UPDATE SET timezone = $2, date_format = $3, items_per_page = $4, updated_at = NOW()`,
[
user.id,
body.timezone || "america-los_angeles",
body.dateFormat || "mdy",
body.itemsPerPage || 20,
],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Preferences PATCH error:", error)
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
}
}
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`,
[user.id],
)
const websiteTheme = result.rows[0]?.website_theme || "spidey"
return NextResponse.json({ websiteTheme })
} catch (error) {
console.error("Website theme GET error:", error)
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
}
}
export async function PUT(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const theme = body.websiteTheme || "spidey"
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
[user.id, JSON.stringify({ website_theme: theme })],
)
return NextResponse.json({ success: true, websiteTheme: theme })
} catch (error) {
console.error("Website theme PUT error:", error)
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
}
}
+36
View File
@@ -0,0 +1,36 @@
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 })
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const now = Date.now()
const elapsed = now - prevTime
const currentCpu = process.cpuUsage()
const user = currentCpu.user - prevCpu.user
const sys = currentCpu.system - prevCpu.system
const totalUs = user + sys
// CPU time (ms) / wall time (ms) * 100 = % of one core
const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0
prevCpu = currentCpu
prevTime = now
const mem = process.memoryUsage()
return NextResponse.json({
rssMB: Math.round(mem.rss / 1024 / 1024),
cpuPct,
cores: os.cpus().length,
})
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser } from "@/lib/auth"
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super 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]
)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("Error deleting user:", error)
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 })
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { avatar } = await request.json()
if (!avatar || typeof avatar !== "string") {
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
}
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
if (!allowed) {
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
}
// Approximate decoded size: base64 is ~4/3 of original
const base64Data = avatar.split(",")[1] || ""
const estimatedBytes = Math.round(base64Data.length * 0.75)
if (estimatedBytes > MAX_AVATAR_BYTES) {
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
}
await query(
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
[avatar, user.id],
)
return NextResponse.json({ avatar })
} catch (error) {
console.error("Avatar upload error:", error)
return NextResponse.json({ error: "Failed to update avatar" }, { status: 500 })
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
const phone = request.nextUrl.searchParams.get("phone")
if (!phone) {
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
}
try {
const result = await query(
`SELECT id, username, first_name, last_name, phone, avatar_url
FROM users
WHERE phone = $1 AND deleted_at IS NULL
LIMIT 1`,
[phone],
)
if (result.rows.length === 0) {
return NextResponse.json({ found: false })
}
const user = result.rows[0]
return NextResponse.json({
found: true,
user: {
id: user.id,
username: user.username,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
avatar: user.avatar_url,
},
})
} catch {
return NextResponse.json({ error: "Lookup failed" }, { status: 500 })
}
}
+99
View File
@@ -0,0 +1,99 @@
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(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
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,
r.name AS role
FROM users u
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
LIMIT $1 OFFSET $2`,
[limit, offset]
)
const users = result.rows.map((row: any) => ({
id: row.id,
name: `${row.first_name} ${row.last_name}`,
email: row.email,
role: row.role.toLowerCase(),
active: row.active,
avatar: avatarSvgUrl(`${row.first_name} ${row.last_name}`),
createdAt: row.created_at,
}))
return NextResponse.json({ users }, { status: 200 })
} catch (error) {
console.error("Error fetching users:", error)
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only super 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
const username = email.split("@")[0]
const passwordHash = await hashPassword(password)
const result = await query(
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
)
const roleId = (
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
).rows[0]?.id
if (roleId) {
await query(
`INSERT INTO user_roles (user_id, role_id, assigned_by)
VALUES ($1, $2, $3)`,
[result.rows[0].id, roleId, sessionUser.id]
)
}
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error: any) {
console.error("Error creating user:", error)
if (error?.constraint === "uq_users_username" || error?.constraint === "uq_users_email") {
return NextResponse.json({ error: "A user with this email or username already exists" }, { status: 409 })
}
return NextResponse.json({ error: error?.message || "Failed to create user" }, { status: 500 })
}
}
+44
View File
@@ -0,0 +1,44 @@
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 {
const currentUser = await getSessionUser()
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (currentUser.role !== "admin" && currentUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const q = request.nextUrl.searchParams.get("q") || ""
if (!q.trim()) {
return NextResponse.json({ users: [] })
}
const result = await query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users
WHERE deleted_at IS NULL
AND id != $1
AND (LOWER(first_name || ' ' || last_name) LIKE LOWER($2)
OR LOWER(email) LIKE LOWER($2))
ORDER BY first_name ASC
LIMIT 10`,
[currentUser.id, `%${q}%`],
)
const users = result.rows.map((row: any) => ({
id: row.id,
name: row.name,
email: row.email,
avatar: avatarSvgUrl(row.name),
}))
return NextResponse.json({ users })
} catch (error) {
console.error("User search error:", error)
return NextResponse.json({ error: "Search failed" }, { status: 500 })
}
}