bc83af8e00
Build & Auto-Repair / build (push) Has been cancelled
Hardened db.ts — added statement_timeout: 30000, idle_in_transaction_session_timeout: 10000, created transaction() helper (BEGIN/COMMIT/ROLLBACK). Multi-step API routes wrapped in transactions — Events POST, Conversations POST use atomic blocks. Fixed leads pagination — status filter pushed into SQL WHERE (no client-side filtering after LIMIT/OFFSET), added SELECT COUNT(*) for total. Rewrote dashboard — SQL aggregations (COUNT + GROUP BY + date_trunc) replace loading all leads into memory. Optimized conversations GET — merged duplicate correlated subqueries into single LEFT JOIN LATERAL. Created migration 021_performance_indexes.sql — 8 missing indexes + set_session_user_context() function caching current_user_hierarchy_level() as session variable (avoids per-query RLS join). Fixed build error — duplicate const other in conversations route. Also fixed a TypeScript never error in dashboard breakdown map. Verified — npx tsc --noEmit clean, npm test (13 pass, 7 integration skip gracefully), npx next build succeeds.
233 lines
11 KiB
TypeScript
233 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import { getSessionUser } from "@/lib/auth"
|
|
import { query, transaction } from "@/lib/db"
|
|
import { sendEventConfirmation } from "@/lib/email"
|
|
|
|
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 start = searchParams.get("start")
|
|
const end = searchParams.get("end")
|
|
const status = searchParams.get("status")
|
|
|
|
let sql = `SELECT e.id, e.user_id, e.participant_id, e.developer_id, e.lead_id, e.conversation_id,
|
|
e.title, e.description, e.participant_notes, e.event_type,
|
|
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
|
|
e.client_name, e.client_email, e.client_phone,
|
|
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
|
|
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
|
|
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
|
|
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
|
|
d.id AS d_id, d.first_name AS d_first, d.last_name AS d_last, d.email AS d_email, d.avatar_url AS d_avatar,
|
|
dr.role_id AS d_role_id, dr2.name AS d_role_name, dr2.display_name AS d_role_display,
|
|
l.id AS l_id, l.company_name, l.contact_name
|
|
FROM scheduled_events e
|
|
JOIN users u ON u.id = e.user_id
|
|
LEFT JOIN users p ON p.id = e.participant_id
|
|
LEFT JOIN users d ON d.id = e.developer_id
|
|
LEFT JOIN leads l ON l.id = e.lead_id
|
|
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
|
|
LEFT JOIN roles r ON r.id = ur.role_id
|
|
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
|
|
LEFT JOIN roles pr2 ON pr2.id = pr.role_id
|
|
LEFT JOIN user_roles dr ON dr.user_id = e.developer_id
|
|
LEFT JOIN roles dr2 ON dr2.id = dr.role_id`
|
|
const params: unknown[] = []
|
|
let idx = 1
|
|
|
|
if (user.role !== "super_admin") {
|
|
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
|
|
params.push(user.id)
|
|
idx++
|
|
} else {
|
|
sql += ` WHERE 1=1`
|
|
}
|
|
|
|
if (start) {
|
|
sql += ` AND (e.start_time IS NULL OR e.start_time >= $${idx})`
|
|
params.push(start)
|
|
idx++
|
|
}
|
|
|
|
if (end) {
|
|
sql += ` AND (e.start_time IS NULL OR e.start_time <= $${idx})`
|
|
params.push(end)
|
|
idx++
|
|
}
|
|
|
|
if (status) {
|
|
sql += ` AND e.status = $${idx}`
|
|
params.push(status)
|
|
idx++
|
|
}
|
|
|
|
sql += " ORDER BY e.start_time ASC"
|
|
|
|
const result = await query(sql, params, user.id)
|
|
|
|
const events = result.rows.map((r: any) => ({
|
|
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,
|
|
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
|
|
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
|
|
developer: r.d_id ? { id: r.d_id, name: `${r.d_first} ${r.d_last}`, email: r.d_email, role: r.d_role_display || r.d_role_name, avatar: r.d_avatar } : null,
|
|
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
|
|
clientName: r.client_name || null,
|
|
clientEmail: r.client_email || null,
|
|
clientPhone: r.client_phone || null,
|
|
createdAt: r.created_at,
|
|
}))
|
|
|
|
return NextResponse.json({ events })
|
|
} catch (error) {
|
|
console.error("Events GET error:", error)
|
|
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const user = await getSessionUser()
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
|
|
|
const {
|
|
participantId, developerId, leadId, conversationId,
|
|
title, description, eventType,
|
|
startTime, endTime, durationMinutes,
|
|
clientName, clientEmail, clientPhone,
|
|
} = await request.json()
|
|
|
|
if (!title) {
|
|
return NextResponse.json({ error: "Title is required" }, { status: 400 })
|
|
}
|
|
if (eventType !== "website_creation" && !startTime) {
|
|
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
|
|
}
|
|
|
|
// 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]
|
|
|
|
// 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],
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
return { r, usersMap }
|
|
}, user.id)
|
|
|
|
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,
|
|
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
|
|
participantEmail: participant?.email || null,
|
|
title,
|
|
description: description || undefined,
|
|
eventType: eventType || "meeting",
|
|
startTime,
|
|
endTime: endTime || undefined,
|
|
durationMinutes: durationMinutes || undefined,
|
|
}).catch((err) => console.error("Email error:", err))
|
|
|
|
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, createdAt: r.created_at,
|
|
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
|
|
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,
|
|
},
|
|
}, { status: 201 })
|
|
} catch (error) {
|
|
console.error("Events POST error:", error)
|
|
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
|
|
}
|
|
}
|