Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
Build & Auto-Repair / build (push) Has been cancelled
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||
} from "lucide-react"
|
||||
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||
import { sanitizeSvg } from "@/lib/sanitize"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
DialogFooter, DialogClose,
|
||||
@@ -1041,7 +1042,7 @@ const formatPreviewContent = (content: string) => {
|
||||
{stickerData.emoji ? (
|
||||
<span className="text-7xl block text-center">{stickerData.emoji}</span>
|
||||
) : (
|
||||
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
||||
<div className="w-full" dangerouslySetInnerHTML={{ __html: sanitizeSvg(stickerData.url).replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
||||
)}
|
||||
</div>
|
||||
) : voiceUrl ? (
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export async function POST() {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`,
|
||||
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { query, transaction } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET() {
|
||||
@@ -18,13 +18,18 @@ export async function GET() {
|
||||
u.email AS other_user_email,
|
||||
u.phone AS other_user_phone,
|
||||
u.avatar_url AS other_user_avatar_url,
|
||||
(SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||
lm.last_message_data->>'content' AS last_message,
|
||||
(lm.last_message_data->>'created_at')::timestamptz 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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_build_object('content', content, 'created_at', created_at) AS last_message_data
|
||||
FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) lm ON true
|
||||
WHERE c.id IN (
|
||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||
)
|
||||
@@ -80,23 +85,27 @@ export async function POST(request: NextRequest) {
|
||||
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
|
||||
const result = await transaction(async (client) => {
|
||||
const convResult = await client.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],
|
||||
)
|
||||
await client.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 otherResult = await client.query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
const other = otherUser.rows[0]
|
||||
return { conversationId, other: otherResult.rows[0] }
|
||||
})
|
||||
|
||||
const { conversationId, other } = result
|
||||
|
||||
return NextResponse.json({
|
||||
conversation: {
|
||||
|
||||
+111
-78
@@ -48,74 +48,22 @@ function stageToStatus(name: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
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, rangeOverride?: { start: Date; end: Date }) {
|
||||
const { start, end } = rangeOverride || 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 }
|
||||
}
|
||||
|
||||
const stageStatusSql = `
|
||||
CASE
|
||||
WHEN ls.name = 'New' THEN 'open'
|
||||
WHEN ls.name = 'Contacted' THEN 'contacted'
|
||||
WHEN ls.name IN ('Qualified', 'Interested', 'Demo Scheduled', 'Negotiation') THEN 'pending'
|
||||
WHEN ls.name = 'Closed Won' THEN 'closed'
|
||||
WHEN ls.name = 'Closed Lost' THEN 'ignored'
|
||||
ELSE 'open'
|
||||
END`
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
@@ -138,19 +86,107 @@ export async function GET(request: NextRequest) {
|
||||
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 ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
|
||||
|
||||
const currentCounts = countStatuses(currentLeads)
|
||||
const prevCounts = countStatuses(prevLeads)
|
||||
// Status counts for current period (SQL aggregation)
|
||||
const countSql = `
|
||||
SELECT ${stageStatusSql} AS status, COUNT(*) AS count
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $1 AND l.created_at <= $2
|
||||
${ownerFilter}
|
||||
GROUP BY ${stageStatusSql}`
|
||||
|
||||
const totalLeads = currentLeads.length
|
||||
const currentCountRows = await query(
|
||||
countSql,
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), user.id],
|
||||
)
|
||||
|
||||
const prevCountRows = await query(
|
||||
countSql,
|
||||
isAdmin
|
||||
? [prevRange.start.toISOString(), prevRange.end.toISOString()]
|
||||
: [prevRange.start.toISOString(), prevRange.end.toISOString(), user.id],
|
||||
)
|
||||
|
||||
function rowsToCounts(rows: any[]) {
|
||||
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
for (const r of rows) {
|
||||
const s = r.status as keyof typeof counts
|
||||
if (s in counts) counts[s] = parseInt(r.count, 10)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const currentCounts = rowsToCounts(currentCountRows.rows)
|
||||
const prevCounts = rowsToCounts(prevCountRows.rows)
|
||||
|
||||
const totalLeads = currentCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
|
||||
const prevTotal = prevCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
|
||||
const closedLeads = currentCounts.closed
|
||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
||||
|
||||
const mappedLeads = currentLeads.map((r: any) => ({
|
||||
// Monthly/weekly breakdown via date_trunc
|
||||
const isMonthly = period === "6months" || period === "12months"
|
||||
const truncUnit = isMonthly ? "month" : "day"
|
||||
const breakdownSql = `
|
||||
SELECT DATE_TRUNC($1, l.created_at) AS period_start,
|
||||
${stageStatusSql} AS status,
|
||||
COUNT(*) AS count
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $2 AND l.created_at <= $3
|
||||
${isAdmin ? "" : "AND l.assigned_to = $4"}
|
||||
GROUP BY DATE_TRUNC($1, l.created_at), ${stageStatusSql}
|
||||
ORDER BY period_start ASC`
|
||||
const breakdownParams = isAdmin
|
||||
? [truncUnit, start.toISOString(), end.toISOString()]
|
||||
: [truncUnit, start.toISOString(), end.toISOString(), user.id]
|
||||
const breakdownResult = await query(breakdownSql, breakdownParams)
|
||||
|
||||
// Build monthly breakdown from aggregated data
|
||||
const breakdownMap: Record<string, { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }> = {}
|
||||
for (const r of breakdownResult.rows) {
|
||||
const d = new Date(r.period_start)
|
||||
const label = isMonthly
|
||||
? d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
if (!breakdownMap[label]) {
|
||||
breakdownMap[label] = { label, total: 0, open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
}
|
||||
const count = parseInt(r.count, 10)
|
||||
breakdownMap[label].total += count
|
||||
const statusKey = r.status as 'open' | 'contacted' | 'pending' | 'closed' | 'ignored'
|
||||
breakdownMap[label][statusKey] = count
|
||||
}
|
||||
const monthlyBreakdown = Object.values(breakdownMap)
|
||||
|
||||
// Recent 10 leads
|
||||
const recentSql = `
|
||||
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
|
||||
${ownerFilter}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 10`
|
||||
const recentResult = await query(
|
||||
recentSql,
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), user.id],
|
||||
)
|
||||
|
||||
const recentLeads = recentResult.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
@@ -158,7 +194,7 @@ export async function GET(request: NextRequest) {
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: r.status,
|
||||
status: stageToStatus(r.stage_name),
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
@@ -170,17 +206,14 @@ export async function GET(request: NextRequest) {
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
|
||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined)
|
||||
|
||||
const trends = {
|
||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
||||
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
||||
totalLeads: computeTrend(totalLeads, prevTotal),
|
||||
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),
|
||||
prevTotal > 0 ? Math.round((prevCounts.closed / prevTotal) * 100) : 0),
|
||||
}
|
||||
|
||||
const stats = {
|
||||
@@ -192,9 +225,9 @@ export async function GET(request: NextRequest) {
|
||||
ignoredLeads: currentCounts.ignored,
|
||||
conversionRate,
|
||||
monthlyBreakdown,
|
||||
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||
leadsPerMonth: monthlyBreakdown.map((m) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||
trends,
|
||||
recentLeads: mappedLeads.slice(0, 10),
|
||||
recentLeads,
|
||||
statusDistribution: [
|
||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||
|
||||
+79
-93
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { query, transaction } from "@/lib/db"
|
||||
import { sendEventConfirmation } from "@/lib/email"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -119,87 +119,85 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
|
||||
[
|
||||
user.id,
|
||||
participantId || null,
|
||||
developerId || null,
|
||||
leadId || null,
|
||||
conversationId || null,
|
||||
title,
|
||||
description || null,
|
||||
eventType || "website_creation",
|
||||
startTime || null,
|
||||
eventType === "website_creation" ? null : (endTime || null),
|
||||
eventType === "website_creation" ? null : (durationMinutes || null),
|
||||
clientName || null,
|
||||
clientEmail || null,
|
||||
clientPhone || null,
|
||||
],
|
||||
user.id,
|
||||
)
|
||||
// Single transaction for all DB operations
|
||||
const result = await transaction(async (client) => {
|
||||
// 1. Insert event
|
||||
const ins = await client.query(
|
||||
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
|
||||
[
|
||||
user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
|
||||
title, description || null, eventType || "website_creation", startTime || null,
|
||||
eventType === "website_creation" ? null : (endTime || null),
|
||||
eventType === "website_creation" ? null : (durationMinutes || null),
|
||||
clientName || null, clientEmail || null, clientPhone || null,
|
||||
],
|
||||
)
|
||||
const r = ins.rows[0]
|
||||
|
||||
const r = result.rows[0]
|
||||
// 2. Auto-update lead stage if website creation event
|
||||
if (r.event_type === "website_creation" && leadId) {
|
||||
const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
|
||||
if (stageResult.rows.length > 0) {
|
||||
await client.query(
|
||||
"UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL",
|
||||
[stageResult.rows[0].id, leadId],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-update lead to "pending" when a website creation event is created
|
||||
if (r.event_type === "website_creation" && leadId) {
|
||||
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
|
||||
if (stageResult.rows.length > 0) {
|
||||
await query(
|
||||
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
|
||||
[stageResult.rows[0].id, leadId],
|
||||
// 3. Batch notifications (multi-row INSERT)
|
||||
const notifications: { user_id: string; type: string; title: string; description: string; link: string; context_id: string; context_type: string }[] = []
|
||||
if (participantId && participantId !== user.id) {
|
||||
notifications.push({
|
||||
user_id: participantId, type: "event_scheduled",
|
||||
title: `Meeting scheduled: ${title}`,
|
||||
description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||
})
|
||||
}
|
||||
if (developerId && developerId !== user.id) {
|
||||
notifications.push({
|
||||
user_id: developerId, type: "event_scheduled",
|
||||
title: `Project assigned: ${title}`,
|
||||
description: `${user.firstName} ${user.lastName} assigned a project to you`,
|
||||
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||
})
|
||||
}
|
||||
if (notifications.length > 0) {
|
||||
const placeholders = notifications.map((_, i) =>
|
||||
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`
|
||||
).join(", ")
|
||||
const flatParams = notifications.flatMap(n => [n.user_id, n.type, n.title, n.description, n.link, n.context_id, n.context_type])
|
||||
await client.query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) VALUES ${placeholders}`,
|
||||
flatParams,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (participantId && participantId !== user.id) {
|
||||
await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
participantId,
|
||||
"event_scheduled",
|
||||
`Meeting scheduled: ${title}`,
|
||||
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||
`/calendar`,
|
||||
r.id,
|
||||
"scheduled_event",
|
||||
],
|
||||
)
|
||||
}
|
||||
// 4. Fetch participant + developer in one query
|
||||
const idsToFetch = [participantId, developerId].filter(Boolean) as string[]
|
||||
let usersMap: Record<string, { id: string; first_name: string; last_name: string; email: string }> = {}
|
||||
if (idsToFetch.length > 0) {
|
||||
const userPlaceholders = idsToFetch.map((_, i) => `$${i + 1}`).join(", ")
|
||||
const userRows = await client.query(
|
||||
`SELECT id, first_name, last_name, email FROM users WHERE id IN (${userPlaceholders})`,
|
||||
idsToFetch,
|
||||
)
|
||||
for (const row of userRows.rows) {
|
||||
usersMap[row.id] = row
|
||||
}
|
||||
}
|
||||
|
||||
// Notify developer
|
||||
if (developerId && developerId !== user.id) {
|
||||
await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
developerId,
|
||||
"event_scheduled",
|
||||
`Project assigned: ${title}`,
|
||||
`${user.firstName} ${user.lastName} assigned a project to you`,
|
||||
`/calendar`,
|
||||
r.id,
|
||||
"scheduled_event",
|
||||
],
|
||||
)
|
||||
}
|
||||
return { r, usersMap }
|
||||
}, user.id)
|
||||
|
||||
const participantResult = participantId ? await query(
|
||||
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
|
||||
[participantId],
|
||||
) : null
|
||||
const participant = participantResult?.rows[0] || null
|
||||
|
||||
const developerResult = developerId ? await query(
|
||||
`SELECT id, first_name, last_name, email FROM users WHERE id = $1`,
|
||||
[developerId],
|
||||
) : null
|
||||
const dev = developerResult?.rows[0] || null
|
||||
const { r, usersMap } = result
|
||||
const participant = participantId ? usersMap[participantId] || null : null
|
||||
const dev = developerId ? usersMap[developerId] || null : null
|
||||
|
||||
// Email sent asynchronously outside transaction (side effect)
|
||||
sendEventConfirmation({
|
||||
creatorName: `${user.firstName} ${user.lastName}`,
|
||||
creatorEmail: user.email,
|
||||
@@ -215,28 +213,16 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
event: {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
participantId: r.participant_id,
|
||||
developerId: r.developer_id,
|
||||
leadId: r.lead_id,
|
||||
conversationId: r.conversation_id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
participantNotes: r.participant_notes,
|
||||
eventType: r.event_type,
|
||||
startTime: r.start_time,
|
||||
endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes,
|
||||
status: r.status,
|
||||
id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_id,
|
||||
leadId: r.lead_id, conversationId: r.conversation_id,
|
||||
title: r.title, description: r.description, participantNotes: r.participant_notes,
|
||||
eventType: r.event_type, startTime: r.start_time, endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes, status: r.status, createdAt: r.created_at,
|
||||
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
|
||||
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
|
||||
participant: participant ? { id: participantId, name: `${participant.first_name} ${participant.last_name}`, email: participant.email } : null,
|
||||
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
|
||||
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
|
||||
clientName: r.client_name || null,
|
||||
clientEmail: r.client_email || null,
|
||||
clientPhone: r.client_phone || null,
|
||||
createdAt: r.created_at,
|
||||
clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
|
||||
},
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import crypto from "crypto"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { phone, token: clientToken } = await request.json()
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Only SUPER_ADMIN can generate invites." }, { status: 403 })
|
||||
}
|
||||
|
||||
const { phone } = await request.json()
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = clientToken || crypto.randomBytes(24).toString("hex")
|
||||
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 recentCount = await query(
|
||||
`SELECT COUNT(*) AS cnt FROM invites WHERE created_at > NOW() - INTERVAL '1 hour'`,
|
||||
)
|
||||
if (parseInt(recentCount.rows[0]?.cnt || "0", 10) >= 10) {
|
||||
return NextResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429 })
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString("hex")
|
||||
await query(
|
||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||
[token, phone],
|
||||
@@ -19,7 +42,8 @@ export async function POST(request: NextRequest) {
|
||||
const inviteUrl = `${origin}/join/${token}`
|
||||
|
||||
return NextResponse.json({ token, inviteUrl })
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("Invite generation error:", error)
|
||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
+46
-23
@@ -43,47 +43,74 @@ export async function GET(request: NextRequest) {
|
||||
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
|
||||
|
||||
const conditions: string[] = ["l.deleted_at IS NULL"]
|
||||
|
||||
if (period !== "all") {
|
||||
const range = getPeriodDateRange(period)
|
||||
if (range) {
|
||||
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
|
||||
conditions.push(`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})`
|
||||
conditions.push(`(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}`
|
||||
conditions.push(`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
|
||||
// Push status filter into SQL (avoid client-side filtering after pagination)
|
||||
const statusStageMap: Record<string, string[]> = {
|
||||
open: ["New"],
|
||||
contacted: ["Contacted"],
|
||||
pending: ["Qualified", "Interested", "Demo Scheduled", "Negotiation"],
|
||||
closed: ["Closed Won"],
|
||||
ignored: ["Closed Lost"],
|
||||
}
|
||||
if (status !== "all") {
|
||||
const stageNames = statusStageMap[status]
|
||||
if (stageNames) {
|
||||
const stagePlaceholders = stageNames.map((_, i) => `$${paramIdx + i}`)
|
||||
conditions.push(`ls.name IN (${stagePlaceholders.join(", ")})`)
|
||||
params.push(...stageNames)
|
||||
paramIdx += stageNames.length
|
||||
}
|
||||
}
|
||||
|
||||
const result = await query(sql, params)
|
||||
const whereClause = conditions.join(" AND ")
|
||||
|
||||
let leads = result.rows.map((r: any) => {
|
||||
// Total count (same filters, no pagination)
|
||||
const countResult = await query(
|
||||
`SELECT COUNT(*) AS total FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE ${whereClause}`,
|
||||
params.slice(0, paramIdx - 1),
|
||||
)
|
||||
const total = parseInt(countResult.rows[0]?.total || "0", 10)
|
||||
|
||||
// Data query with pagination
|
||||
const dataSql = `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 ${whereClause}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
||||
const dataParams = [...params, limit, offset]
|
||||
const result = await query(dataSql, dataParams)
|
||||
|
||||
const leads = result.rows.map((r: any) => {
|
||||
const s = stageToStatus(r.stage_name)
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -106,11 +133,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
if (status !== "all") {
|
||||
leads = leads.filter((l: any) => l.status === status)
|
||||
}
|
||||
|
||||
return NextResponse.json(leads)
|
||||
return NextResponse.json({ leads, total })
|
||||
} catch (error) {
|
||||
console.error("Leads API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
if (sessionUser.role !== "super_admin" && sessionUser.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const phone = request.nextUrl.searchParams.get("phone")
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||
|
||||
Reference in New Issue
Block a user