feat: add Facebook account tracking and management
- Introduced new migration for `facebook_accounts` and `facebook_scrape_logs` tables. - Updated the scraping logic to handle Facebook accounts, including success and failure tracking. - Implemented API endpoints for managing Facebook accounts and their scrape logs. - Enhanced user permissions to restrict access to Facebook account management to admins and super admins. - Added a dialog component for displaying and managing Facebook accounts in the UI. - Updated lead fetching logic to include user role checks for assignment and access.
This commit is contained in:
@@ -48,7 +48,7 @@ function stageToStatus(name: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLeadsInRange(start: Date, end: Date) {
|
||||
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,
|
||||
@@ -59,8 +59,11 @@ async function fetchLeadsInRange(start: Date, end: Date) {
|
||||
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`,
|
||||
[start.toISOString(), end.toISOString()]
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), userId]
|
||||
)
|
||||
return result.rows.map((r: any) => ({
|
||||
...r,
|
||||
@@ -118,6 +121,8 @@ export async function GET(request: NextRequest) {
|
||||
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")
|
||||
@@ -134,8 +139,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const [currentLeads, prevLeads] = await Promise.all([
|
||||
fetchLeadsInRange(start, end),
|
||||
fetchLeadsInRange(prevRange.start, prevRange.end),
|
||||
fetchLeadsInRange(start, end, user.id, isAdmin),
|
||||
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
||||
])
|
||||
|
||||
const currentCounts = countStatuses(currentLeads)
|
||||
|
||||
@@ -3,12 +3,28 @@ 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 })
|
||||
@@ -33,6 +49,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
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)
|
||||
|
||||
@@ -111,6 +111,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -123,6 +123,15 @@ export async function POST(request: NextRequest) {
|
||||
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",
|
||||
@@ -142,7 +151,7 @@ export async function POST(request: NextRequest) {
|
||||
body.description || null,
|
||||
body.source || null,
|
||||
stageId,
|
||||
body.assignedUserId === "none" ? null : body.assignedUserId,
|
||||
assignedUserId,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ export async function POST(request: NextRequest) {
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { type, title, description, link, userId } = await request.json()
|
||||
const targetUserId = userId || user.id
|
||||
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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ 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
|
||||
|
||||
@@ -2,6 +2,9 @@ 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()
|
||||
@@ -12,6 +15,18 @@ export async function POST(request: NextRequest) {
|
||||
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],
|
||||
|
||||
@@ -7,6 +7,9 @@ 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)
|
||||
|
||||
@@ -7,6 +7,9 @@ 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") || ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user